React에서 Redux사용하기

Posted by yunki kim on July 25, 2021

  리액트 애플리케이션에서 리덕스를 사용하면, 상태 업데이트에 대한 로직을 모듈로 따로 분리 해서 컴포넌트 파일과 별개로 관리할 수 있기 떄문에 코드 유지보수에 도움이 된다. 여러 컴포넌트에서 동일한 상태를 공유 할 때 유용하고, 실제 업데이트가 필요한 컴포넌트만 리렌더링 되게 쉽게 최적화 할 수 있다.

  바닐라에서 리덕스를 사용할 거면, dispatch, subscribe를 사용해야 했지만, 리액트에서 리덕스를 사용할 때에는 react-redux라는 라이브러리에서 제공하는 유틸 함수(connect)와 컴포넌트(Provider)를 사용해 리덕스 관련 작업을 한다.

  리액트에서 리덕스를 사용할 때에는 통상적으로 presentational component와 container component를 분리하여 사용한다. Presentational component는 props를 받아와 보여주기만 하는 컴포넌트이고 container component는 리덕스와 연동되어 있는 컴포넌트로, 리덕스로 부터 상태를 받아 오거나 스토어에 액션을 디스패치 한다. 따라서 다음과 같은 패턴을 사용한다

파일 구조

  리덕스를 사용할 때 강제되는 디렉터리 구조는 없지만 통상적으로 리덕스를 사용할 때 actions, constants, reducers라는 3개의 디렉터리를 만들어서 각각 액션 생성 함수, 액션 타입, 리듀서를 저장해 사용한다. 하지만 새로운 액션을 만들어야 할때 세 종류의 파일을 모두 수정해야 하기 때문에 불편하다. 이때 사용하는 패턴이 ducks이다. Ducks는 디렉터리를 기준으로 파일을 나누는 것이 아닌 위의 3종류를 각 하나의 파일에 몰아서 넣는 방식이다. 

  modules라는 디렉터리를 만들고 다음과 같은 코드들을 작성하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//modules/counter.js
//액션 타입 정의
//액션 타입은 '모듈 이름/액션 이름' 형식으로 해서 중복을 피한다
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
 
//액션 생성 함
export const increase = () => ({type: INCREASE});
export const decrease = () => ({type: DECREASE});
 
//초기 상태
const initialState = {
    number: 0
};
 
//reducer 함수
function counter (state = initialState, action) {
    switch (action.type) {
        case INCREASE:
            return {
                number: state.number + 1;
            }
        case DECREASE:
            return {
                number: state.number + 1;
            }
        default:
            return state;
    }
}
 
export default counter;
 
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
//modules/todos.js
//액션 타입 지정
const CHANGE_INPUT = 'todos/CHNAGE_INPUT';
const INSERT = 'todos/INSERT';
const TOGGLE = 'todos/TOGGLE';
const REMOVE = 'todos/REMOVE';
 
//액션 생성 함수
export const changeInput = (input) => ({
   type: CHANGE_INPUT,
   input
});
 
let id = 3;//insert 한번 호출 시 +1
export const insert = (text) => ({
   type: INSERT,
   todo: {
      id: id++,
      text,
      done: false
   }
});
 
export const toggle = (id) => ({
   type: TOGGLE,
   id
});
 
export const remove = (id) => ({
   type: REMOVE,
   id
});
 
//초기 상태
const initialState = {
   input: '',
   todos: [
      {
         id: 1,
         text: 'learn basic redux',
         done: true,
      },
      {
         id: 2,
         text: 'use react and redux',
         done: false,
      }
   ],
};
 
//reducer 
function todos(state = initialState, action) {
   switch(action.type) {
      case CHANGE_INPUT:
         return {
            ...state,
            input: action.input
         };
      case INSERT:
         return {
            ...state,
            todos: state.todos.concat(action.todo)
         };
      case TOGGLE:
         return {
            ...state,
            todos: state.todos.map(todo =>
                todo.id === action.id ? {...todo, done: !todo.done} : todo
            )
         };
      case REMOVE:
         return {
            ...state,
            todos: state.todos.filter(todo => todo.id !== action.id)
         };
      default:
         return state;
   }
}
 
export default todos;
cs

  위와 같이 리듀서를 여러개 만들어도 되지만, createStore함수를 사용해서 스토어를 만들때는 리듀서를 하나만 사용해야 한다. 따라서 기존에 만들었던 리듀서를 하나로 합쳐야 하는데, 이는 리덕스의 combineReducers()를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
//modules/index.js
import {combineReducers} from 'redux';
import counter from "./counter";
import todos from "./todos";
 
const rootReducer = combineReducers({
    counter,
    todos,
});
 
export default rootReducer;
cs

스토어 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
//크롬 확장 개발자 도구인 Redux DevTools를 사용하기 위한 세팅
import {composeWithDevTools} from "redux-devtools-extension";
 
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer from "./modules";
 
const store = createStore(rootReducer, composeWithDevTools());
 
ReactDOM.render(
  //리액트 컴포넌트에서 스토어를 사용할 수 있게 App컴포넌트를 react-redux에서 제공하는
    //Provider 컴포넌트로 감싼다
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
 
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
 
cs

  이제 컴포넌트에서 리덕스 스토어에 접근해 원하는 상태를 받아오고, 액션 디스패치를 하자. 리덕스 스토어와 연동된 컴폰너트를 컨테이너 컴포넌트라 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//containers/CounterContainer.js
import React from 'react';
import {connect} from 'react-redux';
import Counter from '../components/Counter';
import {increase, decrease} from '../modules/counter';
 
const CounterContainer = ({number, increase, decrease}) => {
    return (
        <Counter number={number} onIncrease={increase} onDecrease={decrease}/>
    );
};
 
//아래 두 함수의 반환 객체 내부 값들은 컴포넌트의 props로 전달 된다.
//state를 파라미터로 받고, 이 값은 현재 스토어가 지닌 상태이다.
const mapStateToProps = state => ({
    number: state.counter.number,
});
 
//스토어의 내장 함수 dispatch를 파라미터로 받아 온다.
const mapDispatchToProps = dispatch => ({
    increase: () => {
        dispatch(increase());
    },
    decrease: () => {
        dispatch(decrease());
    },
});
 
//컴포넌트를 리덕스와 연동하기 위해 react-redux에서 제공하는 connect()를 사용해야 한다
//connect는 첫 인자로 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위한 설정 함수
//두 번쨰 인자로 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위한 함수를 사용한다.
export default connect(
    mapStateToProps,
    mapDispatchToProps,
    //connect는 호출되면 또 다른 함수를 반환하는데, 이 함수의 인자로
    //연동할 컴포넌트를 넣어 주면 된다.
)(CounterContainer); //연동할 컴포넌
cs

  만약 위의 코드에서 액션을 디스패치하기 위해 액션 생성함수를 호출하고, dispatch로 감싸는 것을 하기 싫다면 bindActionCreators를 사용하면 된다.

1
2
3
4
5
6
7
//스토어의 내장 함수 dispatch를 파라미터로 받아 온다.
const mapDispatchToProps = dispatch => (
    bindActionCreators({
        increase,
        decrease,
    }, dispatch)
);
cs

또는 다음과 같은 방식을 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//containers/CounterContainer.js
import React from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import Counter from '../components/Counter';
import {increase, decrease} from '../modules/counter';
 
const CounterContainer = ({number, increase, decrease}) => {
    return (
        <Counter number={number} onIncrease={increase} onDecrease={decrease}/>
    );
};
 
 
//컴포넌트를 리덕스와 연동하기 위해 react-redux에서 제공하는 connect()를 사용해야 한다
//connect는 첫 인자로 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위한 설정 함수
//두 번쨰 인자로 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위한 함수를 사용한다.
export default connect(
    state => ({
        number: state.counter.number,
    }),
    //두번쨰 인자로 액션 생성 함수로 이루어진 객체 형태를 넣으면
    //connect함수가 내부적으로 bindActionCreators작업을 한다
    {
        increase,
        decrease,
    },
    //connect는 호출되면 또 다른 함수를 반환하는데, 이 함수의 인자로
    //연동할 컴포넌트를 넣어 주면 된다.
)(CounterContainer); //연동할 컴포넌
cs

흐름 정리

  다음 코드를 순차적으로 보면 흐름이 이해될 거다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
//modules/todos.js
//액션 타입 지정
const CHANGE_INPUT = 'todos/CHNAGE_INPUT';
const INSERT = 'todos/INSERT';
const TOGGLE = 'todos/TOGGLE';
const REMOVE = 'todos/REMOVE';
 
//액션 생성 함수
export const changeInput = (input) => ({
   type: CHANGE_INPUT,
   input
});
 
let id = 3;//insert 한번 호출 시 +1
export const insert = (text) => ({
   type: INSERT,
   todo: {
      id: id++,
      text,
      done: false
   }
});
 
export const toggle = (id) => ({
   type: TOGGLE,
   id
});
 
export const remove = (id) => ({
   type: REMOVE,
   id
});
 
const initialState = {
   input: '',
   todos: [
      {
         id: 1,
         text: 'learn basic redux',
         done: true,
      },
      {
         id: 2,
         text: 'use react and redux',
         done: false,
      }
   ],
};
 
function todos(state = initialState, action) {
   switch(action.type) {
      case CHANGE_INPUT:
         return {
            ...state,
            input: action.input
         };
      case INSERT:
         return {
            ...state,
            todos: state.todos.concat(action.todo)
         };
      case TOGGLE:
         return {
            ...state,
            todos: state.todos.map(todo =>
                todo.id === action.id ? {...todo, done: !todo.done} : todo
            )
         };
      case REMOVE:
         return {
            ...state,
            todos: state.todos.filter(todo => todo.id !== action.id)
         };
      default:
         return state;
   }
}
 
export default todos;
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//containers/TodosContainer.js
import React from 'react';
import {connect} from 'react-redux';
import {changeInput, insert, toggle, remove} from '../modules/todos';
import Todos from '../components/Todos';
 
const TodosContainer = ({
    input,
    todos,
    changeInput,
    insert,
    toggle,
    remove,
}) => {
    return (
        <Todos
            input={input}
            todos={todos}
            onChangeInput={changeInput}
            onInsert={insert}
            onToggle={toggle}
            onRemove={remove}
        />
    );
};
 
export default connect(
    ({todos}) => ({
        input: todos.input,
        todos: todos.todos,
    }),
    {
        changeInput,
        insert,
        toggle,
        remove,
    },
)(TodosContainer);
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//components/Todos.js
import React from 'react';
 
const TodoItem = ({todo, onToggle, onRemove}) => {
     return (
         <div>
             <input
                 type="checkbox"
                 onClick={() => onToggle(todo.id)}
                 checked={todo.done}
                 readOnly={true}
             />
             <span style=>
                 {todo.text}
             </span>
             <button onClick={() => onRemove(todo.id)}>delete</button>
         </div>
     );
}
 
const Todos = ({
    input,
    todos,
    onChangeInput,
    onInsert,
    onToggle,
    onRemove,
}) => {
    const onSubmit = (e) => {
        e.preventDefault();
        onInsert(input);
        onChangeInput('');//등록 후 인풋 초기화
    }
    const onChange = e => onChangeInput(e.target.value);
    return (
        <div>
            <form onSubmit={onSubmit}>
                <input type="text" value={input} onChange={onChange}/>
                <button type={"submit"}>submit</button>
            </form>
            <div>
                {todos.map(todo => (
                    <TodoItem
                        todo={todo}
                        key={todo.id}
                        onToggle={onToggle}
                        onRemove={onRemove}
                    />
                ))}
            </div>
        </div>
    );
};
 
export default Todos;
cs

리덕스 간략화

  redux-action을 사용하면 보다 코드를 더 깔끔하게 쓸 수 있다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import {createAction, handleActions} from 'redux-actions';
//액션 타입 지정
const CHANGE_INPUT = 'todos/CHNAGE_INPUT';
const INSERT = 'todos/INSERT';
const TOGGLE = 'todos/TOGGLE';
const REMOVE = 'todos/REMOVE';
 
//액션 생성 함수
//createAction으로 액션을 만들면 액션에 필요한 추가 데이터는 payload라는 이름을 사용한다.
//{type: CHANGE_INPUT, payload: input}
export const changeInput = createAction(CHANGE_INPUT, input => input);
 
let id = 3;//insert 한번 호출 시 +1
export const insert = createAction(INSERT, text => ({
   id: id++,
   text,
   done: false,
}));
 
export const toggle = createAction(TOGGLE, id => id);
 
export const remove = createAction(REMOVE, id => id);
 
const initialState = {
   input: '',
   todos: [
      {
         id: 1,
         text: 'learn basic redux',
         done: true,
      },
      {
         id: 2,
         text: 'use react and redux',
         done: false,
      }
   ],
};
 
//createAction을 사용했기 때문에 모든 값은 action.payload로 조회 한다.
const todos = handleActions({
   [CHANGE_INPUT]: (state, action) => ({...state, input: action.payload}),
   [INSERT]: (state, action) => ({
      ...state,
      todos: state.todos.concat(action.payload),
   }),
   //이와 같이 payload를 사용하는 대신 비구조화 할당을 통해 payload이름을 새로 설정할 수 있다.
   [TOGGLE]: (state, {payload: id}) => ({
      ...state,
      todos: state.todos.map(todo =>
          todo.id === id ? {...todo, done: !todo.done} : todo,
      ),
   }),
   [REMOVE]: (state, action) => ({
      ...state,
      todos: state.todos.filter(todo => todo.id !== action.payload),
   }),
}, initialState);
 
export default todos;
cs

 immer를 사용하면 복잡한 객체의 수정에 대한 코드를 줄일 수있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import {createAction, handleActions} from 'redux-actions';
import produce from "immer";
 
//액션 타입 지정
const CHANGE_INPUT = 'todos/CHNAGE_INPUT';
const INSERT = 'todos/INSERT';
const TOGGLE = 'todos/TOGGLE';
const REMOVE = 'todos/REMOVE';
 
//액션 생성 함수
//createAction으로 액션을 만들면 액션에 필요한 추가 데이터는 payload라는 이름을 사용한다.
//{type: CHANGE_INPUT, payload: input}
export const changeInput = createAction(CHANGE_INPUT, input => input);
 
let id = 3;//insert 한번 호출 시 +1
export const insert = createAction(INSERT, text => ({
   id: id++,
   text,
   done: false,
}));
 
export const toggle = createAction(TOGGLE, id => id);
 
export const remove = createAction(REMOVE, id => id);
 
const initialState = {
   input: '',
   todos: [
      {
         id: 1,
         text: 'learn basic redux',
         done: true,
      },
      {
         id: 2,
         text: 'use react and redux',
         done: false,
      }
   ],
};
 
//createAction을 사용했기 때문에 모든 값은 action.payload로 조회 한다.
const todos = handleActions({
   [CHANGE_INPUT]: (state, {payload: input}) =>
      produce(state, draft => {
         draft.input = input;
      }),
   [INSERT]: (state, {payload: todo}) =>
       produce(state, draft => {
          draft.todos.push(todo);
       }),
   //이와 같이 payload를 사용하는 대신 비구조화 할당을 통해 payload이름을 새로 설정할 수 있다.
   [TOGGLE]: (state, {payload: id}) =>
      produce(state, draft => {
         const todo = draft.todos.find(todo => todo.id === id);
         todo.done = !todo.done;
      }),
   [REMOVE]: (state, {payload: id}) =>
      produce(state, draft => {
         const index = draft.todos.findIndex(todo => todo.id === id);
         draft.todos.splice(index, 1);
      }),
}, initialState);
 
export default todos;
 
cs

Hooks를 사용해 컨테이너 컴포넌트 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//containers/CounterContainer.js
import
 React, {useCallback} from 'react';
import {bindActionCreators} from 'redux';
import {useSelector, useDispatch} from 'react-redux';
import Counter from '../components/Counter';
import {increase, decrease} from '../modules/counter';
 
const CounterContainer = () => {
    //useSelect()의 파라미터는 connect()의 첫 파라미터인
    //mapStateToProps에 해당한다
    const number = useSelector(state => state.counter.number);
    //컴포넌트 내부에서 스토어의 내장 함수 dispatch를 사용할 수 있게 해준다
    const dispatch = useDispatch();
    return (
        <Counter
            number={number}
            onIncrease={() => dispatch(increase())}
            onDecrease={() => dispatch(decrease())}
        />
    );
};
 
export default CounterContainer;
cs

  위의 코드의 경우 컴포넌트가 리렌더링 되면 onIncrease와 onDecrease가 새롭게 만들어 진다. 따라서 성능 최적화를 위해서라면 useCallback으로 액션을 dispatch하는 함수를 감싸야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React, {useCallback} from 'react';
import {bindActionCreators} from 'redux';
import {useSelector, useDispatch} from 'react-redux';
import Counter from '../components/Counter';
import {increase, decrease} from '../modules/counter';
 
const CounterContainer = () => {
    //useSelect()의 파라미터는 connect()의 첫 파라미터인
    //mapStateToProps에 해당한다
    const number = useSelector(state => state.counter.number);
    //컴포넌트 내부에서 스토어의 내장 함수 dispatch를 사용할 수 있게 해준다
    const dispatch = useDispatch();
    const onIncrease = useCallback(() => dispatch(increase()), [dispatch]);
    const onDecrease = useCallback(() => dispatch(decrease()), [decrease]);
    return (
        <Counter
            number={number}
            onIncrease={onIncrease}
            onDecrease={onDecrease}
        />
    );
};
 
export default CounterContainer;
 
 
 
//useSelector의 경우 다음과 같이 비구조화 할당도 할 수 있다.
const {input, todos} = useSelector(({todos}) => ({
    input: todos.input,
    todos: todos.todos,
}));
cs

useSotre

   컴포넌트 내부에서 리덕스 스토어 객체를 사용할 수 있다. 정말 간혹 스토어에 직접 접근해야 할때만 사용한다.

1
2
3
4
5
const store = useStore();
sotre.dispatch({type: 'a'});
store.getState();
 
 
cs

  useActions

    useActions는 원래 redux-react에 내장될 계획이었으나 훅을 사용할때 개념적 오버헤드와 syntax상의 복잡을 야기한다는 의견으로 인해 제외 되었다. 하지만 다음 링크에서 코드를 복사해 useActions를 사용할 수 있다.

https://react-redux.js.org/api/hooks#recipe-useactions

 

Hooks | React Redux

API > Hooks: the `useSelector` and `useDispatch` hooks`

react-redux.js.org

    useActions는 액션 생성 함수를 액션을 dispatch하는 함수로 변환해 준다. 액션 생성 함수를 사용해 액션 객체를 만들고, 이를 스토어에 디스패치하는 작업을 해 주는 함수를 자동으로 만들어 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//lib/useActions.js
import { bindActionCreators } from 'redux'
import { useDispatch } from 'react-redux'
import { useMemo } from 'react'
 
//actions->액션 생성 함수로 이뤄진 배열
//deps->이 배열 안에 이쓴 원소가 바뀌면 액션을 디스패치 하는 함수를 새로 만든다.
export function useActions(actions, deps) {
    const dispatch = useDispatch()
    return useMemo(
        () => {
            if (Array.isArray(actions)) {
                return actions.map(a => bindActionCreators(a, dispatch))
            }
            return bindActionCreators(actions, dispatch)
        },
        deps ? [dispatch, ...deps] : [dispatch]
    );
}
 
cs

  useActions는 다음과 같이 사용할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, {useCallback} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {changeInput, insert, toggle, remove} from '../modules/todos';
import Todos from '../components/Todos';
import useActions from '../lib/useActions';
 
const TodosContainer = () => {
    const {input, todos} = useSelector(({todos}) => ({
        input: todos.input,
        todos: todos.todos,
    }));
    const [onChangeInput, onInsert, onToggle, onRemove] = useActions(
        [changeInput, insert, toggle, remove],
        []
    );
    return (
        <Todos
            input={input}
            todos={todos}
            onChangeInput={onChangeInput}
            onInsert={onInsert}
            onToggle={onToggle}
            onRemove={onRemove}
        />
    )
};
 
export default TodosContainer;
cs

connect()와 훅의 차이

  connect()를 사용하면 해당 컨테이너 컴포넌트의 부모 컴포넌트가 리렌더링 될 때 해당 컨테이너 컴포넌트의 props가 바뀌지 않았다면 리렌더링이 자동으로 방지 된다. 반면, 훅은 이러한 최적화가 자동으로 이루어 지지 않기 때문에 React.memo를 컨테이어 컴포넌트에 사용해야 한다.