React Redux 알아보기
React에서 많이 사용하는 상태 관리 라이브러리인 Redux에 대해 정리한 내용입니다.
1. Redux
Redux는 상태 업데이트와 관련된 로직을 효율적으로 관리하는 라이브러리입니다. 여러 개의 컴포넌트에서 같은 상태를 공유할 때도 손쉽게 상태를 변경하고 관리할 수 있습니다. 프로젝트의 규모가 커지면 서로 다른 컴포넌트를 오가며 props와 state를 관리하는 것이 복잡해지기 때문에 외부에 store를 두고 관리하는 방식입니다.
전역 상태만 관리하고자 한다면 Context API를 사용하는 것으로 충분하지만, 프로젝트 규모가 큰 경우엔 리덕스를 사용하여 효율성과 유지 보수성을 높여주는 것이 좋습니다. 또한 리덕스는 리액트에 종속되는 라이브러리가 아니기 때문에 Angular, Vue, VanilaJS와 같은 다른 프론트엔드 라이브러리와 함께 사용이 가능합니다.
1.1. Redux의 3가지 원칙
리덕스 사용을 위해서는 다음의 3가지 원칙을 지켜야 합니다.
- 단일 스토어
하나의 애플리케이션 내부에는 하나의 스토어를 만들어 사용해야 합니다. 여러 개의 스토어를 만들어 사용이 가능하지만 상태 관리가 복잡해지기 때문에 권장되지 않습니다. 스토어 내부에는 앱 상태와 리듀서 및 내장 함수가 포함되어 있습니다. - 읽기 전용 상태
리덕스는 읽기 전용 상태입니다. 읽기 전용 상태인 것은 불변성이 유지되어야하기 때문인데 성능 유지를 위해 데이터 변경시 얕은 비교(shallow equality)를 하기 때문입니다. - 순수한 함수 리듀서
리듀서는 변화를 일으켜 새로운 상태를 반환합니다. state와 action을 파라미터로 전달받고 파라미터 외의 값에는 의존하지 않도록 조건에 맞는 순수한 함수로 사용해야 합니다.
1.2. Redux 사용을 위해 알아둬야 할 키워드
리덕스를 사용하기전에 다음 키워드들의 의미를 알아두는 것이 좋습니다.
- 액션 (Action)
상태에 변화가 필요할 때 발생하는 하나의 객체를 의미합니다. 액션의 이름인 type 필드를 필수로 포함한 구조로 되어 있습니다.
{ type: 'ADD_DATA' data: { id: 1, name: 'redux' } }
- 액션 생성 함수 (Action Creator)
액션 객체를 만들어주는 함수입니다. 파라미터를 받아와서 액션 객체 형태로 만들어줍니다.
const addData = (data) => ({ type: 'ADD_DATA', data: { id: 1, name: 'redux' } });
- 리듀서 (Reducer)
변화를 일으켜 새로운 상태를 반환하는 함수입니다. state와 action을 파라미터로 받아옵니다.
function reducer(state, action) { switch (action.type) { case 'INCREASE': return { ...state, value: state.value + 1 }; default: return state; } }
- 스토어 (Store)
리덕스를 사용하기 위해 프로젝트 내부에 단일로 생성되는 저장소입니다. 스토어에는 상태와 리듀서 및 내장 함수가 포함되어 있습니다. - 디스패치 (Dispatch)
스토어의 내장 함수이며 액션을 발생시킵니다. 액션 객체를 파라미터로 받아서 호출합니다. dispatch가 호출되면 스토어의 리듀서가 실행되어 새로운 상태를 반환합니다.
const onClickIncrease = (event) => { dispatch({ type: 'INCREASE' }); };
- 구독 (Subscribe)
스토어의 내장 함수이며 action이 dispatch 될 때마다 호출됩니다. 리스너 함수를 파라미터로 받아서 호출합니다.
const listener = () => { console.log('State updated.'); }; store.subscribe(listener);
리덕스의 동작에 대해 정리해보면 프로젝트 내부의 상태를 하나의 단일 스토어에 저장하고 dispatch를 이용하여 action을 발생시킨 뒤 reducer를 통해 상태를 변경하는 것이라고 볼 수 있습니다.
2. React에서 Redux 사용하기
React 프로젝트에 Redux를 적용하여 사용하는 방법을 정리한 내용입니다. 다음 항목들을 사용하여 리덕스를 적용했습니다.
- 리덕스 모듈
액션 관련 코드들과 리듀서를 한 파일에 모아서 작성. (Ducks 패턴) - react-redux
Provider를 이용하여 store 연동.
useSelector, useDispatch를 이용하여 store 상태 조회 및 업데이트. - redux-actions
createAction을 이용하여 액션 생성 함수 작성.
handleActions를 이용하여 switch-case문 없이 리듀서 함수를 작성. - redux-devtools-extension
store 상태와 action에 따른 상태 변화 확인.
다음 명령어를 실행하여 react-redux, redux-actions와 redux-devtools-extension 라이브러리를 설치해줍니다.
$ yarn add react-redux redux-actions redux-devtools-extension
리덕스 개발자 도구 사용을 위해 크롬 웹스토어에서 확장 프로그램을 설치해줍니다.
리덕스 사용을 위해 src 하위에 다음과 같이 파일 구조를 만들어줍니다.
src
├── modules
│ ├── config.js
│ ├── counterModule.js
│ └── todoModule.js
├── screens
│ ├── Counter.js
│ └── Todo.js
├── ...
App.js
index.js
2.1. store, redux-devtools-extension, redux-logger 적용하기
react-redux에서 제공하는 Provider 컴포넌트를 이용하여 store를 사용할 수 있도록 수정해줍니다. 다음과 같이 App 컴포넌트를 감싸주고 store를 props로 전달해주도록 작성해줍니다. 스토어를 생성하는 createStore 함수에는 루트 리듀서를 파라미터로 넣어줍니다.
/* src/index.js */
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules/config';
const store = createStore(rootReducer);
ReactDOM.render(
<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();
다음으로 redux-devtools-extension과 redux-logger를 적용해줍니다. 리덕스 개발자 도구 사용을 위해 createStore 함수의 두번째 파라미터로 composeWithDevTools 함수를 넣고 logger 사용을 위해 그 안에는 applyMiddleware(logger)를 넣어줍니다.
/* src/index.js */
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import { createLogger } from 'redux-logger';
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer from './modules/config';
const logger = createLogger();
const store = createStore(rootReducer, composeWithDevTools(applyMiddleware(logger)));
ReactDOM.render(
<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();
리액트 애플리케이션을 실행하고 크롬 개발자 도구에서 Redux 탭을 보면 리덕스 개발자 도구가 적용된 것을 확인할 수 있습니다.
2.2. 루트 리듀서와 리덕스 모듈 만들기
다음과 같이 루트 리듀서를 만들어줍니다. combineReducers 함수를 이용하여 사용할 리듀서들을 루트 리듀서에 합쳐줍니다.
/* src/modules/config.js */
import { combineReducers } from 'redux';
import counter from './counterModule';
import todo from './todoModule';
const rootReducer = combineReducers({
counter,
todo
});
export default rootReducer;
다음으로 액션 관련 코드들과 리듀서를 한 파일에 모아서 모듈을 작성해줍니다. counterModule.js와 todoModule.js 파일을 각각 만들어줍니다.
/* src/modules/counterModule.js */
import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';
////////////////////////////////////////
// States & Variables
////////////////////////////////////////
const initialState = {
number: 0
};
////////////////////////////////////////
// Action Types
////////////////////////////////////////
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
////////////////////////////////////////
// Action Creators
////////////////////////////////////////
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
////////////////////////////////////////
// Reducer
////////////////////////////////////////
const counter = handleActions(
{
[INCREASE]: (state, action) =>
produce(state, draft => {
draft.number = draft.number + 1;
}),
[DECREASE]: (state, action) =>
produce(state, draft => {
draft.number = draft.number - 1;
})
},
initialState
);
export default counter;
/* src/modules/todoModule.js */
import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';
////////////////////////////////////////
// States & Variables
////////////////////////////////////////
const todoItem = {
id: '',
title: '',
checked: false
};
const initialState = {
inputTitle: '',
todoList: []
};
let id = 0;
////////////////////////////////////////
// Action Types
////////////////////////////////////////
const CHANGE_INPUT = 'todo/CHANGE_INPUT';
const INSERT = 'todo/INSERT';
const TOGGLE = 'todo/TOGGLE';
const REMOVE = 'todo/REMOVE';
////////////////////////////////////////
// Action Creators
////////////////////////////////////////
export const changeInput = createAction(CHANGE_INPUT, inputTitle => inputTitle);
export const insert = createAction(INSERT, title => title);
export const toggle = createAction(TOGGLE, id => id);
export const remove = createAction(REMOVE, id => id);
////////////////////////////////////////
// Reducer
////////////////////////////////////////
const todo = handleActions(
{
[CHANGE_INPUT]: (state, { payload: inputTitle }) =>
produce(state, draft => {
draft.inputTitle = inputTitle;
}),
[INSERT]: (state, { payload: title }) =>
produce(state, draft => {
const insertItem = {...todoItem};
insertItem.id = ++id;
insertItem.title = title;
draft.todoList.push(insertItem);
}),
[TOGGLE]: (state, { payload: id }) =>
produce(state, draft => {
const toggleItem = draft.todoList.find(item => item.id === id);
toggleItem.checked = !toggleItem.checked;
}),
[REMOVE]: (state, { payload: id }) =>
produce(state, draft => {
const removeIndex = draft.todoList.findIndex(item => item.id === id);
draft.todoList.splice(removeIndex, 1);
}),
},
initialState,
);
export default todo;
작성한 코드를 보면 redux-actions의 createAction과 handleActions 함수를 사용하였습니다. createAction은 다음과 같이 액션 생성 함수를 만들 때 사용합니다. createAction으로 액션을 만들 때 액션이 데이터를 필요로 하는 경우엔 두번째 파라미터에 데이터를 받도록 해줍니다. 액션 생성 함수는 액션에 필요한 데이터를 전달 받아 action.payload에 넣어줍니다.
...
////////////////////////////////////////
// Action Types
////////////////////////////////////////
const CHANGE_INPUT = 'todo/CHANGE_INPUT';
const INSERT = 'todo/INSERT';
const TOGGLE = 'todo/TOGGLE';
const REMOVE = 'todo/REMOVE';
////////////////////////////////////////
// Action Creators
////////////////////////////////////////
export const changeInput = createAction(CHANGE_INPUT, inputTitle => inputTitle);
export const insert = createAction(INSERT, title => title);
export const toggle = createAction(TOGGLE, id => id);
export const remove = createAction(REMOVE, id => id);
...
handleActions 함수는 리듀서를 더 간편하게 작성하기 위해 사용합니다. 기존에 switch-case문으로 만들었던 리듀서를 더 간단하고 가독성을 높여서 만들 수 있습니다.
/* Reducer (switch-case) */
const counterReducer = (state = initialState, action) => {
switch (action.type) {
case INCREASE:
return {
...state,
number: state.number + 1
};
case DECREASE:
return {
...state,
number: state.number - 1
};
default:
return state;
}
};
/* Reducer (handleActions) */
const counter = handleActions(
{
[INCREASE]: (state) =>
produce(state, draft => {
draft.number = draft.number + 1;
}),
[DECREASE]: (state) =>
produce(state, draft => {
draft.number = draft.number - 1;
})
},
initialState
);
또한 액션에 추가로 넘겨준 데이터를 확인할 때는 모두 action.payload로 확인해야해서 가독성이 떨어지는데 다음과 같이 구조 분해 할당을 사용하여 payload를 새로운 변수명으로 설정해주면 가독성을 좀 더 높일 수 있습니다.
/* action.payload */
const todo = handleActions(
{
[CHANGE_INPUT]: (state, action) =>
produce(state, draft => {
draft.inputTitle = action.payload;
}),
...
},
initialState
);
/* action.payload destructuring */
const todo = handleActions(
{
[CHANGE_INPUT]: (state, { payload: inputTitle }) =>
produce(state, draft => {
draft.inputTitle = inputTitle;
}),
...
},
initialState
);
2.3. 컴포넌트 만들기
react-redux에서 제공하는 useSelector와 useDispatch Hook을 사용하여 Counter와 Todo 컴포넌트를 만들어줍니다.
/* src/screens/Counter.js */
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increase, decrease } from '../modules/counterModule';
function Counter() {
////////////////////////////////////////
// Hooks
////////////////////////////////////////
const counter = useSelector(state => state.counter);
const dispatch = useDispatch();
////////////////////////////////////////
// Functions
////////////////////////////////////////
const onIncrease = useCallback(() => {
dispatch(increase());
}, [dispatch]);
const onDecrease = useCallback(() => {
dispatch(decrease());
}, [dispatch]);
////////////////////////////////////////
// View
////////////////////////////////////////
return (
<>
<div>
<h1>{counter.number}</h1>
<div>
<button onClick={onIncrease}>+</button>
<button onClick={onDecrease}>-</button>
</div>
</div>
</>
);
};
export default Counter;
/* src/screens/Todo.js */
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todoModule';
function Todo() {
////////////////////////////////////////
// Hooks
////////////////////////////////////////
const { inputTitle, todoList } = useSelector(state => state.todo);
const dispatch = useDispatch();
////////////////////////////////////////
// Functions
////////////////////////////////////////
const onSubmit = useCallback((event) => {
event.preventDefault();
dispatch(insert(inputTitle));
dispatch(changeInput(''));
}, [dispatch, inputTitle]);
const onChange = useCallback((event) => {
dispatch(changeInput(event.target.value));
}, [dispatch]);
const onToggle = useCallback((id) => {
dispatch(toggle(id));
}, [dispatch]);
const onRemove = useCallback((id) => {
dispatch(remove(id));
}, [dispatch]);
////////////////////////////////////////
// View
////////////////////////////////////////
return (
<div>
<form onSubmit={onSubmit}>
<input value={inputTitle} onChange={onChange} />
<button type="submit">Add</button>
</form>
<div>
{
todoList.map((item, index) => (
<div key={index}>
<input
type="checkbox"
checked={item.checked}
readOnly={true}
onClick={() => onToggle(item.id)}
/>
<span style={{ textDecoration: item.checked ? 'line-through' : 'none' }}>
{item.title}
</span>
<button onClick={() => onRemove(item.id)}>X</button>
</div>
))
}
</div>
</div>
);
};
export default Todo;
useSelector Hook을 이용하면 스토어에 등록된 상태를 조회할 수 있습니다.
const counter = useSelector(state => state.counter);
const todo = useSelector(state => state.todo);
useDispatch Hook을 이용하면 스토어의 dispatch 내장 함수를 사용할 수 있습니다. 액션을 디스패치해야할 때 사용합니다.
const dispatch = useDispatch();
...
const onChange = useCallback((event) => {
dispatch(changeInput(event.target.value));
}, [dispatch]);
마지막으로 App 컴포넌트는 다음과 같이 작성해줍니다.
/* src/App.js */
import React from 'react';
import Counter from './screens/Counter';
import Todo from './screens/Todo';
function App() {
return (
<>
<Counter />
<hr />
<Todo />
</>
);
}
export default App;
애플리케이션을 실행하면 다음과 같이 counter와 todo 리스트가 동작하는 것을 확인할 수 있습니다. 또한 redux-logger와 리덕스 개발자 도구에서 액션에 따른 상태의 변화를 확인할 수 있습니다.
이상으로 React에서 Redux를 사용하는 방법에 대해 알아봤습니다.
※ Reference
- redux.js.org, Getting Started with Redux, https://redux.js.org/introduction/getting-started
- milooy.github.io, Redux, http://milooy.github.io/TIL/React/redux.html
- velog.io/@velopert, Redux (1) 소개 및 개념정리, https://velog.io/@velopert/Redux-1-%EC%86%8C%EA%B0%9C-%EB%B0%8F-%EA%B0%9C%EB%85%90%EC%A0%95%EB%A6%AC-zxjlta8ywt
- velog.io/@velopert, Redux (3) 리덕스를 리액트와 함께 사용하기, https://velog.io/@velopert/Redux-3-%EB%A6%AC%EB%8D%95%EC%8A%A4%EB%A5%BC-%EB%A6%AC%EC%95%A1%ED%8A%B8%EC%99%80-%ED%95%A8%EA%BB%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-nvjltahf5e
- react.vlpt.us, 리덕스, https://react.vlpt.us/redux/
- react.vlpt.us, 2. 리덕스의 3가지 규칙, https://react.vlpt.us/redux/02-rules.html
- react.vlpt.us, 4. 리덕스 모듈 만들기, https://react.vlpt.us/redux/04-make-modules.html
- react.vlpt.us, 6. 리덕스 개발자도구 적용하기, https://react.vlpt.us/redux/06-redux-devtools.html
- darrengwon.tistory.com, Redux 소개 및 활용, https://darrengwon.tistory.com/406?category=889133
- darrengwon.tistory.com, redux-action으로 액션, 리듀서를 간략히 작성하기, https://darrengwon.tistory.com/644?category=889133
- darrengwon.tistory.com, React에 Redux, react-redux 적용하기, https://darrengwon.tistory.com/184?category=889133
- darrengwon.tistory.com, react-redux의 hook : useDispatch, useSelector, https://darrengwon.tistory.com/559?category=889133
- 『리액트를 다루는 기술』 길벗(2019) 김민준 지음, 16장 리덕스 라이브러리 이해하기 (p414 ~ p428)
- 『리액트를 다루는 기술』 길벗(2019) 김민준 지음, 17장 리덕를 사용하여 리액트 애플리케이션 상태 관리하기 (p430 ~ p470)