정말 간만의 포스트입니다. 요새 더 멋진 velog 를 개발하느라.. 통 글을 못 쓰고 있었네요! 오늘 준비한 포스트에서는 react-redux 에서 Hooks 를 사용하는 방법에 대해서 알아보도록 하겠습니다.

이번에, 튜토리얼과 함께 영상도 만들어보았습니다... 튜토리얼 진행 과정을 그대로 따라해보고 싶으신분은 위 영상과 함께 진행하시면 도움이 될거예요.

우선, 리액트에 도입된 Hooks 에 대해서 잘 모르시는 분들은 Hooks 완벽 정복하기 포스트를 읽어주세요.

이 포스트는 여러분이 리덕스에 대해서 이미 잘 알고있다는 가정 하에 튜토리얼을 진행하게 됩니다. 만약에 리덕스를 잘 모르신다면 리덕스 시리즈를 먼저 읽어주세요.

Hooks 서포트는 react-redux v7.1 부터 지원이 되는 기능입니다. 이 기능은 현재 alpha 상태이며 아직까지는 정식으로 릴리즈되지 않았습니다. 만약에 변동이 생기거나, 정식적으로 릴리즈 되면 이 포스트에서도 반영하도록 하겠습니다.

현재 useActions 랑 useRedux 가 사라졌습니다.

react-redux 의 Hooks 에 관련한 공식 문서는 여기에 있습니다.

이 포스트에서는 리액트 프로젝트를 생성해서 리덕스를 사용하여 카운터와 Todo List 를 만들어보게 됩니다. 우리는 살면서 과연 개발 공부를 하게 되면서 카운터는 몇번이나 만드는거고, Todo List 는 대체 몇번이나 만들게 되는 걸까요? ㅎㅎ

1. 프로젝트 만들기 및 라이브러리 설치

시간을 단축하기 위하여 우리는 CRA 를 사용하여 프로젝트를 구성하도록 하겠습니다.

$ yarn create react-app react-redux-hooks-tutorial

그리고, 해당 프로젝트 디렉터리에 redux, react-redux 를 설치하세요. react-redux 를 설치 할 때에는 @next 태그를 붙여주세요.

만약 제가 이 포스트 수정을 조금 늦게 했다면, 여러분이 이 포스트를 보는 시점에 이미 정식 릴리즈가 되었을지도 모릅니다. 이 링크 를 확인하여 Hooks 가 정식 릴리즈로 탑재가 되었는지 확인해보세요.

$ yarn add redux react-redux@next redux-devtools-extension

그 다음엔 src 디렉터리에 다음 디렉터리를 생성하세요:

  • components/
  • containers/
  • modules/

우리는 ducks 패턴을 사용해서 액션 / 액션 생성 함수 / 리듀서가 한 파일에 들어있는 리덕스 모듈을 작성 할 것입니다.

2. 카운터 구현하기

가장 먼저, 카운터를 구현해봅시다. 우선 리덕스 모듈부터 만들어보세요.

modules/counter.js

const INCREMENT = 'counter/INCREMENT';
const DECREMENT = 'counter/DECREMENT';

export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });

const initialState = 0;

const counter = (state = initialState, action) => {
  switch (action.type) {
    case INCREMENT:
      return state + 1;
    case DECREMENT:
      return state - 1;
    default:
      return state;
  }
};

export default counter;

그 다음엔, 루트 리듀서를 만드세요. 물론 지금은 리듀서가 하나 뿐이지만 추후 더 만들 것 입니다.

modules/index.js

import { combineReducers } from 'redux';
import counter from './counter';

const rootReducer = combineReducers({
  counter
});

export default rootReducer;

그리고 나서 프로젝트의 엔트리 파일 index.js 에서 스토어를 만들고 Provider 를 통하여 프로젝트에 리덕스를 적용하세요.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer from './modules';
import { Provider } from 'react-redux';

const store = createStore(rootReducer, composeWithDevTools());

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

현재 리덕스 개발자 도구도 적용을 해주었는데요, 만약에 아직 크롬 확장 프로그램을 설치하지 않으셨으면 여기서 설치하세요.

이제 카운터의 프리젠테이셔널 컴포넌트를 만드세요.

import React from 'react';

const Counter = ({ onIncrease, onDecrease, number }) => {
  return (
    <div>
      <h1>{number}</h1>
      <div>
        <button onClick={onIncrease}>+1</button>
        <button onClick={onDecrease}>-1</button>
      </div>
    </div>
  );
};

export default Counter;

이제 컨테이너를 만들어봅시다. 드디어 Hooks 를 사용 할 차례입니다! 기대가 되지않나요?

useSelector

가장 먼저 알아볼 Hook 은 useSelector 입니다. 이 Hook 을 통하여 우리는 리덕스 스토어의 상태에 접근 할 수 있습니다.

useSelector 는 다음과 같이 사용합니다.

const result : any = useSelector(selector : Function, deps : any[])

여기서 selector 는 우리가 기존에 connect 로 사용 할 때 mapStateToProps 와 비슷하다고 생각하시면 됩니다. deps 배열은 어떤 값이 바뀌었을 때 selector 를 재정의 할 지 설정해줍니다. deps 값을 생략 하시면 매번 렌더링 될 때마다 selector 함수도 새로 정의됩니다. 기존의 useCallback 이나 useMemo 에서의 deps 랑 동일하다고 보시면 됩니다. 결국, 코드를 뜯어보면 useSelector 도 내부적으로 useMemo 를 사용하고 있답니다 (참고).

selector 함수를 선언하는게 큰 리소스는 들어가진 않기 때문에 기본적으로는 deps 를 넣지 않아도 큰 문제는 없습니다. 그런데 최적화에 신경이 쓰인다면 작업하실 때 두번째 파라미터로 [] 를 기본적으로 넣는 것도 괜찮을 것 같습니다. 그리고 실제 dep 배열에 넣어야 되는 값이 보인다면, 그걸 넣으면 더욱 좋겠죠.

이 Hook 을 우리 컴포넌트에서 사용한다면 이렇게 사용하면 됩니다.

const counter = useSelector(state => state.counter, []);

만약에 값 하나만 가져오는게 아니라면 이렇게 할 수도 있겠죠?

const { a, b } = useSelector(state => ({ a: state.a, state.b }), [])

useActions (삭제된 기능)

그 다음에는 useActions 를 알아봅시다. 이 Hook 은 기존의 mapDispatchToProps 랑 조금 다릅니다. mapDispatchToProps 는 dispatch 를 파라미터로 가져오는 반면 여기서의 actionCreator 는 그렇지 않습니다.

const boundAC = useActions(actionCreator : Function, deps : any[])
const boundACsObject = useActions(actionCreators : Object<string, Function>, deps : any[])
const boundACsArray = useActions(actionCreators : Function[], deps : any[])

여기서의 deps 는 역시 useSelector 에서 언급한것과 비슷합니다. 생략하셔도 상관은 없습니다만 최적화에 신경쓰신다면 빈 배열을 넣으시거나, 실제로 관계 있는 값을 deps 안에 넣으세요.

위 방식 중에서 boundAC 방식은 액션 생성함수 하나만 사용 할 때 사용합니다.

const onIncrease = useActions(increment)

boundACsObject 는 여러개의 액션 생성함수를 사용 할 때 사용합니다.

const { onIncrease, onDecrease } = useActions({
  onIncrease: increment,
  onDecrease: decrement
});

마지막으로 onACsArray 도 여러개의 액션 생성함수를 사용 할 때 사용 할 수 있는데, 배열 형태로 반환합니다.

const [onIncrease, onDecrease] = useActions([increase, decrease]);

와우.. 깔끔쓰...

CounterContainer 구현

그럼 방금 배운것들을 활용해서 컨테이너를 구현해봅시다.

CounterContainer.js

import React from 'react';
import { useSelector, useActions } from 'react-redux';
import Counter from '../components/Counter';
import { increment, decrement } from '../modules/counter';

const CounterContainer = () => {
  const counter = useSelector(state => state.counter, []);

  const [onIncrease, onDecrease] = useActions([increment, decrement], []);

  return (
    <Counter number={counter} onIncrease={onIncrease} onDecrease={onDecrease} />
  );
};

export default CounterContainer;

이제 이 컴포넌트를 App 에서 렌더링하세요.

App.js

import React from 'react';
import CounterContainer from './containers/CounterContainer';

const App = () => {
  return <CounterContainer />;
};

export default App;

image.png
Edit react-redux-hooks-tutorial

잘 작동하나요?

3. Todo List 만들기

이번에는 Todo List 를 만들어보면서 Redux 의 Hooks 를 더 알아봅시다. 우선 Todo List 를 만들기 위한 todos 리듀서를 만들어주세요.

modules/todos.js

const CHANGE_INPUT = 'todos/CHANGE_INPUT';
const INSERT = 'todos/INSERT';
const TOGGLE_CHECK = 'todos/TOGGLE_CHECK';
const REMOVE = 'todos/REMOVE';

let id = 0;
export const changeInput = input => ({ type: CHANGE_INPUT, payload: input });
export const insert = text => ({
  type: INSERT,
  payload: {
    id: ++id,
    text
  }
});
export const toggleCheck = id => ({ type: TOGGLE_CHECK, payload: id });
export const remove = id => ({ type: REMOVE, payload: id });

const initialState = {
  input: '',
  todos: []
};

const todos = (state = initialState, action) => {
  switch (action.type) {
    case CHANGE_INPUT:
      return {
        ...state,
        input: action.payload
      };
    case INSERT:
      return {
        ...state,
        todos: state.todos.concat({ ...action.payload, done: false })
      };
    case TOGGLE_CHECK:
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? {
                ...todo,
                done: !todo.done
              }
            : todo
        )
      };
    case REMOVE:
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload)
      };
    default:
      return state;
  }
};

export default todos;

그 다음엔, 루트 리듀서에 등록해야겠죠?

modules/index.js

import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';

const rootReducer = combineReducers({
  counter,
  todos
});

export default rootReducer;

그리고, TodoList 를 위한 컴포넌트를 만들어줍시다. 편의상 한 파일에 다 만들어보도록 하겠습니다. 이 컴포넌트를 만드는 과정에서 React.memo 를 사용합니다. 꼭 사용해하는건 아니지만, 업데이트 성능 최적화를 하기 위함입니다.

TodoList 컴포넌트는 input 값과 todos 배열을 props 로 받아오게 되는데, input 이 바뀔 때마다 모든 Todo 항목들이 리렌더링 되는건 비효율적이니 이러한 구조에서는 React.memo 를 사용하여 컴포넌트를 구성하는것을 권장드립니다. 물론, 이렇게 조그마한 앱에서는 최적화 안해도 이런걸로 렉이 걸리지는 않습니다.

components/TodoList.js

import React from 'react';

const TodoItem = React.memo(({ todo, onRemove, onToggle }) => {
  const { id, text, done } = todo;

  return (
    <li style={{ textDecoration: done ? 'line-through' : 'none' }}>
      <span onClick={() => onToggle(id)}>{text}</span>{' '}
      <button onClick={() => onRemove(id)}>삭제</button>
    </li>
  );
});

const TodoItems = React.memo(({ todos, onRemove, onToggle }) =>
  todos.map(todo => (
    <TodoItem
      key={todo.id}
      todo={todo}
      onRemove={onRemove}
      onToggle={onToggle}
    />
  ))
);

const TodoList = ({ todos, input, onRemove, onToggle, onChange, onSubmit }) => {
  return (
    <div>
      <form onSubmit={onSubmit}>
        <input value={input} onChange={onChange} />
        <button type="submit">추가</button>
      </form>
      <ul>
        <TodoItems todos={todos} onRemove={onRemove} onToggle={onToggle} />
      </ul>
    </div>
  );
};

export default TodoList;

useRedux (삭제된 기능)

이번에 알아볼 Hooks 는 useRedux 입니다. 이 Hook 은 useSelector 와 useActions 의 혼합체입니다. 참고로, 아직 이 기능이 완전히 정착된것 까지는 아니지만, useRedux 는 deps 를 넣을 수 없고, 사용했을때 코드가 장황하다는(?) 측면에서 이는 불필요한 Hook 이라는 피드백도 올라오고 있습니다 (참고 [1] [2]). 과연 이 Hook 은 알파 이후에도 남아있을지는 모르겠지만, 한번 사용법을 알아봅시다.

이 Hook 의 사용법은 다음과 같습니다.

const [selectedValue, boundACs] = useRedux(selector, actionCreators)

만약 우리가 CounterContainer 에서 useRedux 를 사용했더라면 이렇게 사용 할 수 있습니다.

  const [counter, [onIncrease, onDecrease]] = useRedux(state => state.counter, [
    increment,
    decrement
  ]);

한번 이를 사용하여 컨테이너를 구현해봅시다.

TodoListContainer 구현

containers/TodoListContainer.js

import React, { useCallback } from 'react';
import { useRedux } from 'react-redux';
import { changeInput, insert, toggleCheck, remove } from '../modules/todos';
import TodoList from '../components/TodoList';

const TodoListContainer = () => {
  const [
    { input, todos },
    [onChangeInput, onInsert, onToggle, onRemove]
  ] = useRedux(
    state => ({
      input: state.todos.input,
      todos: state.todos.todos
    }),
    [changeInput, insert, toggleCheck, remove]
  );

  const onChange = useCallback(
    e => {
      onChangeInput(e.target.value);
    },
    [onChangeInput]
  );

  const onSubmit = useCallback(
    e => {
      e.preventDefault();
      onInsert(input);
      onChangeInput('');
    },
    [input, onChangeInput, onInsert]
  );

  return (
    <TodoList
      input={input}
      todos={todos}
      onChange={onChange}
      onSubmit={onSubmit}
      onToggle={onToggle}
      onRemove={onRemove}
    />
  );
};

export default TodoListContainer;

이제 기존에 App 에서 렌더링하고있던 CounterContainer 를 지우고, TodosListContainer 를 렌더링하세요.

App.js

import React from 'react';
import TodoListContainer from './containers/TodoListContainer';

const App = () => {
  return <TodoListContainer />;
};

export default App;

image.png

투두리스트가 잘 작동하나요?

그런데, 아까 언급했다시피 useRedux 는 부정적인 피드백을 많이 받고있으니, useSelector / useActions 로 다시 구현해주겠습니다.

containers/TodoListContainer.js

import React, { useCallback } from 'react';
import { useSelector, useActions } from 'react-redux';
import { changeInput, insert, toggleCheck, remove } from '../modules/todos';
import TodoList from '../components/TodoList';

const TodoListContainer = () => {
  // todos 리듀서에서 관리하는 객체를 통째로 가져올 거라면 state.todos 로 간소화 시킬 수 있습니다.
  const { input, todos } = useSelector(state => state.todos, []);
  const [onChangeInput, onInsert, onToggle, onRemove] = useActions(
    [changeInput, insert, toggleCheck, remove],
    []
  );

  const onChange = useCallback(
    e => {
      onChangeInput(e.target.value);
    },
    [onChangeInput]
  );

  const onSubmit = useCallback(
    e => {
      e.preventDefault();
      onInsert(input);
      onChangeInput('');
    },
    [input, onChangeInput, onInsert]
  );

  return (
    <TodoList
      input={input}
      todos={todos}
      onChange={onChange}
      onSubmit={onSubmit}
      onToggle={onToggle}
      onRemove={onRemove}
    />
  );
};

export default TodoListContainer;

Edit react-redux-hooks-tutorial

4. 다른 Hooks

이번 실습에서 사용하지 않은, 다른 Hooks 가 두개 더 있습니다.

useDispatch

useDispatch Hook 은 컴포넌트 내에서 dispatch 를 사용 할 수 있게 해줍니다.

import  { useDispatch } from 'react-redux';
const dispatch = useDispatch();

useStore

useStore Hook 은 컴포넌트 내에서 store 를 사용 할 수 있게 해줍니다.

import { useStore } from 'react-redux'
const store = useStore();

정리

원래 기존에 react-redux 에서는 connect 를 통해 컴포넌트에서 리덕스 스토어에 연결을 했지만, 이제는 Hooks 로도 할 수 있게 됐습니다. 물론, 아직 정식 릴리즈 된건 아니지만, 만약 정식 릴리즈가 됐을때 기존 connect 를 사용 하는 컨테이너 컴포넌트를 모두 수정해주어야 할까요?

그렇지는 않습니다. 어쩌다가 특정 컴포넌트를 리팩토링하게 될 때는 겸사겸사 Hooks 를 사용하는 코드로 바꿔주면 좋긴 하겠지만, connect 함수가 deprecated 되는 것이 아니기 때문에 기존에 잘 작동하고 있는 컨테이너 컴포넌트를 굳이 Hooks 로 전환해줄 필요는 없습니다.

단, 새로운 컴포넌트를 만들게 될 때는 Hooks 를 사용하는 것을 추천드립니다. 아무래도 매우 편하기도 하니까요. (어디 까지나 정식 릴리즈 됐을 때 얘기입니다. 알파일때는 프로덕션에서 사용하지 마세요. 나중에 귀찮아지는 일이 벌어질수도 있습니다.)

읽어주셔서 감사합니다 :) 오탈자 있으면 피드백 부탁드려요.