안녕하세요! delay100입니다. 이번 포스팅에서는 16장에서 다뤘던 리덕스를 실제 애플리케이션 상태 관리에 적용해보겠습니다.
대부분의 설명은 주석으로 달아놓았으니 코드에 대한 설명은 주석을 확인해주세요!
책 리액트를 다루는 기술, 개정판의 17장 내용을 다루고 있습니다.
이번 포스팅의 Github 링크
https://github.com/delay-100/study-react/tree/main/ch17/react-redux-tutorial
1. 프로젝트 준비하기
리덕스를 사용하여 리액트 애플리케이션 상태를 관리하는 프로젝트입니다.
리액트 프로젝트에서 리덕스 사용 시 장점
1. 상태 업데이트에 관한 로직을 모듈로 따로 분리하여 컴포넌트 파일과 별개로 관리할 수 있으므로 코드를 유지 보수하는데 도움됨
2. 여러 컴포넌트에서 동일한 상태를 공유해야할 때 매우 유용
3. 실제 업데이트가 필요한 컴포넌트만 리렌더링되도록 쉽게 최적화 가능
리액트 애플리케이션에서 리덕스를 사용할 때는 react-redux라는 라이브러리에서 제공하는 유틸 함수(connect)와 컴포넌트(Provider)를 사용하여 리덕스 관련 작업을 처리합니다.
프로젝트 생성 명령어
yarn create react-app react-redux-tutorial
cd react-redux-tutorial
설치할 라이브러리
* Redux에 대한 설명은 여기를 참고해주세요. (클릭)
yarn add redux react-redux
+ Prettier 설정
코드 스타일을 깔끔하게 하기 위해 Prettier을 설정합니다.
VSCode에서 f1을 누른 후 format 입력 후 Enter (-> format Document)을 누르면 됩니다. 그 후 /react-redux-tutorial의 최상위 디렉터리에 .prettierrc 파일을 생성했습니다.
- .prettier 파일 생성 및 설정 - .prettierrc
{
"singleQuote": true,
"semi": true,
"useTabs":false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80
}
리액트 프로젝트에서 리덕스 사용 시, 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하는 패턴을 가장 많이 사용합니다. 이번 17장에서는 이 방식을 이용하겠습니다.
프레젠테이셔널 컴포넌트?
- 주로 상태 관리가 이루어지지 않고, 그저 props를 받아 와서 화면에 UI를 보여주기만 하면 되는 컴포넌트
컨테이너 컴포넌트?
- 리덕스와 연동되어 있는 컴포넌트
- 리덕스로부터 상태를 받아오기도 하고 리덕스 스토어에 액션을 디스패치하기도 함\
사용할 디렉터리 설명
src/components: UI에 관련된 프레젠테이션 컴포넌트
src/containers: 리덕스와 연동된 컨테이너 컴포넌트
2. UI 만들기
각 파일들 설명
Counter: 숫자를 더하고 뺄 수 있는 카운터 컴포넌트
Todos: 해야 할 일을 추가하고, 체크하고, 삭제할 수 있는 할 일 목록 컴포넌트
- src/components/Counter.js
// Counter 컴포넌트: 숫자를 더하고 뺄 수 있는 카운터 컴포넌트
const Counter = ({ number, onIncrease, onDecrease }) => {
return (
<div>
<h1>{number}</h1>
<div>
<button onClick={onIncrease}>+1</button>
<button onClick={onDecrease}>-1</button>
</div>
</div>
);
};
export default Counter;
- src/components/Todos.js
// Todos.js: 해야 할 일을 추가하고, 체크하고, 삭제할 수 있는 할 일 목록 컴포넌트
// TodoItem 컴포넌트: 이미 저장된 각각의 투두 컴포넌트
const TodoItem = ({ todo, onToggle, onRemove }) => {
return (
<div>
<input type="checkbox" />
<span>예제 텍스트</span>
<button>삭제</button>
</div>
);
};
// Todos 컴포넌트: 반환할 전체 Todo 묶음
const Todos = ({
input, // 인풋에 입력되는 텍스트
todos, // 할 일 목록이 들어 있는 객체
onChangeInput,
onInsert,
onToggle,
onRemove,
}) => {
const onSubmit = (e) => {
e.preventDefault();
};
return (
<div>
<form onSubmit={onSubmit}>
<input />
<button type="submit">등록</button>
</form>
<div>
<TodoItem />
<TodoItem />
<TodoItem />
<TodoItem />
<TodoItem />
<TodoItem />
</div>
</div>
);
};
export default Todos;
- src/App.js
import Counter from './components/Counter';
import Todos from './components/Todos';
const App = () => {
return (
<div>
<Counter number={0} />
<hr />
<Todos />
</div>
);
};
export default App;
3. 리덕스 기본(Ducks 패턴)
2번까지 준비가 되었다면, 프로젝트에 리덕스를 사용해봅시다. 리덕스 사용 시 액션 타입, 액션 생성 함수, 리듀서 코드를 작성해야합니다.
여기서는 Ducks 패턴을 사용하겠습니다. Ducks 패턴을 사용해 액션 타입, 액션 생성 함수, 리듀서를 작성한 코드를 '모듈' 이라고 합니다. 3-1. counter 모듈과 3-2. todos 모듈을 만들어 볼 것입니다.
Ducks 패턴?
액션 타입, 액션 생성 함수, 리듀서 함수를 기능별로 파일 하나에 몰아서 다 작성하는 방식
+일반적인 구조: actions, constants, reducers라는 세 개의 디렉터리를 만들고 그 안에 기능별로 파일을 하나씩 만드는 방식
3-1. counter 모듈
숫자를 늘리고 줄이는 counter 모듈을 만들어봅시다.
- counter 모듈 생성 - src/modules/counter.js
// 1. 액션 타입 정의
// 액션 타입: 대문자, 문자열 내용: '모듈 이름/액션 이름' => 문자열 안에 모듈 이름을 넣어서 액션의 이름이 충돌되지 않게 함
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
// 2. 액션 생성 함수 만들기
// 앞 부분에 export를 붙혀서 추후 이 함수를 다른 파일에서 불러와 사용할 수 있음
export const increase = () => ({ type: INCREASE }); // export: 여러 개를 내보낼 수 있음, export default: 한 개만 내보낼 수 있음
export const decrease = () => ({ type: DECREASE });
// 3. 초기 상태 및 리듀서 함수 만들기
// 3-1. 초기 상태 만들기 - number 값을 설정해줌
const initialState = {
number: 0,
};
// 3-2. 리듀서 함수 만들기 - 현재 상태를 참조하여 새로운 객체를 생성해서 반환하는 코드
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; // export default: 한 개만 내보낼 수 있음, export: 여러 개를 내보낼 수 있음
// export default 불러오는 방식
// import counter from './counter';
// export 불러오는 방식
// import { increase, decrease }, './counter';
3-2. todos 모듈
todo 와 관련된 모듈을 작성합시다. counter보다 조금 더 복잡합니다.
- todos 모듈 생성 - src/modules/todos.js
// 1. 액션 타입 정의하기
const CHANGE_INPUT = 'todos/CHANGE_INPUT'; // 인풋 값을 변경함
const INSERT = 'todos/INSERT'; // 새로운 todo를 등록함
const TOGGLE = 'todos/TOGGLE'; // todo를 체크/체크 해제함
const REMOVE = 'todos/REMOVE'; // todo를 제그함
// 2. 액션 생성 함수 만들기
export const changeInput = (input) => ({
type: CHANGE_INPUT,
input,
});
let id = 3; // id: 각 todo 객체가 갖고 있게 될 고윳값(3인 이유: 초기 상태 작성 시 todo 객체 두 개를 사전에 넣을 것이기 때문)
// insert가 호출될 때마다 id가 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,
});
// 3. 초기 상태 및 리듀서 함수 만들기
// 객체에 한 개 이상의 값이 들어가므로 불변성을 유지해줘야 함(spread 연산자 사용)
// 배열에 변화를 줄 때는 배열 내장 함수를 사용하여 구현하면 됨
const initialState = {
input: '',
todos: [
{
id: 1,
text: '리덕스 기초 배우기',
done: true,
},
{
id: 2,
text: '리액트와 리덕스 사용하기',
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;
3-3. 리듀서 합치기
3-1에서는 counter 리듀서 , 3-2에서는 todos 리듀서를 만들었습니다. 나중에 createStore 함수를 사용하여 스토어를 만들 때는 리듀서를 하나만 사용해야 합니다. 그러므로, 기존에 만든 리듀서를 하나로 합쳐줘야 합니다.
이 작업은 리덕스에서 제공하는 combineReducers라는 유틸 함수를 사용하여 처리합니다.
- 루트 리듀서 만들기 - src/modules/index.js
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';
const rootReducer = combineReducers({
counter,
todos,
});
export default rootReducer;
4. 리덕스 적용(Redux DevTools)
리덕스를 적용하기 전에, Redux DevTools(리덕스 개발자도구) 라는 크롬 확장 프로그램을 먼저 설치해줍시다.
위의 확장 프로그램 설치 시, 리덕스 스토어를 만드는 과정에서 아래의 코드의 형태로 사용하면 됩니다.
const store = createStore(
rootReducer, /* preloadedState, */
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
위와 같이 코드를 적어도 되지만, 여기서는 패키지를 설치해주겠습니다.
- redux-devtools-extension 패키지 설치 명령어
yarn add redux-devtools-extension
- 리덕스 및 리덕스 개발자도구 적용 - src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer from './modules';
// 리액트 애플리케이션에 리덕스 적용하기
// 1. 스토어 생성하기
// const store = createStore(rootReducer);
// 3. 스토어 생성 후 Redux DevTools 연결하기
const store = createStore(rootReducer, composeWithDevTools());
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Provider store={store}>{/* 2. 리액트 컴포넌트에서 스토어를 사용할 수 있도록 App 컴포넌트를 react-redux에서 제공하는 Provider 컴포넌트로 감싸줌 - 이 컴포넌트 사용 시 store를 props 로 전달해줘야 함 */}
<App />
</Provider>
</React.StrictMode>,
);
// 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();
5. 컨테이너 컴포넌트 만들기
컴포넌트에서 리덕스 스토어에 접근해 원하는 상태를 받아오고, 액션도 디스패치 해봅시다.
src 디렉터리에 containers 디렉터리를 생성 후 아래의 컨테이너 컴포넌트를 2개 작성해보겠습니다.
컨테이너 컴포넌트?
리덕스 스토어와 연동된 컴포넌트
5-1. CounterContainer
mapDispatchToProps 함수(increase, decrease 동작)를 제대로 작성하기 전에, console.log로 동작하는 방법을 간단히 알아봅시다.
- CounterContainer 생성1 - src/containers/CounterContainer.js
import { connect } from 'react-redux';
import Counter from '../components/Counter';
const CounterContainer = ({ number, increase, decrease }) => {
// mapStateToProps, mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달됨
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
// mapStateToProps 함수: 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수
const mapStateToProps = (state) => ({
// state: 현재 스토어가 지니고 있는 상태
number: state.counter.number,
});
// mapDispatchToProps 함수: 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수
const mapDispatchToProps = (dispatch) => ({
// store의 내장 함수인 dispatch를 받아옴
// 임시 함수
increase: () => {
console.log('increase');
},
decrease: () => {
console.log('decrease');
},
});
export default connect(mapStateToProps, mapDispatchToProps)(CounterContainer);
// connect 함수 호출 시 또 다른 함수를 반환함
// connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)
// 반환된 함수에 컴포넌트를 파라미터로 넣어 주면 리덕스와 연동된 컴포넌트가 만들어짐
// 위의 코드는 아래처럼 풀어낼 수 있음
// const makeContainer = connect(mapStateToProps, mapDispatchToProps)
// makeContainer(타깃 컴포넌트)
- CounterContainer 생성1 - src/App.js
// import Counter from './components/Counter';
import Todos from './components/Todos';
import CounterContainer from './containers/CounterContainer';
const App = () => {
return (
<div>
{/* <Counter number={0} /> */}
<CounterContainer /> {/* Counter을 CounterContainer로 교체 */}
<hr />
<Todos />
</div>
);
};
export default App;
실행 결과를 보면, increase 버튼을 누르면 increase 버튼이 눌렸다고 console에 찍히고 decrease 버튼을 누ㅂ르면 decrease 버튼이 눌렸다고 console에 찍히는 것을 볼 수 있습니다.
console.log 대신에, 액션 생성 함수를 불러와 액션 객체를 만들고 디스패치 해주겠습니다.
위의 생성1 코드에서 mapDispatchToProps 내부의 코드만 조금 바꾸었습니다.
- CounterContainer 생성2 - src/containers/CounterContainer.js
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';
const CounterContainer = ({ number, increase, decrease }) => {
// mapStateToProps, mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달됨
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
// mapStateToProps 함수: 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수
const mapStateToProps = (state) => ({
// state: 현재 스토어가 지니고 있는 상태
number: state.counter.number,
});
// mapDispatchToProps 함수: 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수
const mapDispatchToProps = (dispatch) => ({
// store의 내장 함수인 dispatch를 받아옴
increase: () => {
dispatch(increase());
},
decrease: () => {
dispatch(decrease());
},
// // 임시 함수
// increase: () => {
// console.log('increase');
// },
// decrease: () => {
// console.log('decrease');
// },
});
export default connect(mapStateToProps, mapDispatchToProps)(CounterContainer);
// connect 함수 호출 시 또 다른 함수를 반환함
// connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)
// 반환된 함수에 컴포넌트를 파라미터로 넣어 주면 리덕스와 연동된 컴포넌트가 만들어짐
// 위의 코드는 아래처럼 풀어낼 수 있음
// const makeContainer = connect(mapStateToProps, mapDispatchToProps)
// makeContainer(타깃 컴포넌트)
실행을 위해 아래의 코드와 동일하게 작성하면 됩니다.
- CounterContainer 생성1 - src/App.js
실행 결과는 동일하지만 이보다 더 간단한 방법이 3가지 있습니다. 1, 2, 3번 순서대로 더 간단해집니다.(3번이 가장 간단)
CounterContainer 생성2 - src/containers/CounterContainer.js의 코드를 1, 2, 3 중에 하나로 바꾸고, CounterContainer 생성1 - src/App.js의 코드에서 CounterContainer만 해당하는 것으로 다시 불러와주면 됩니다.
1. 더 간단하게 CounterContainer 만들기
위에서는 mapStateToProps 함수, mapDispatchToProps 함수를 선언했습니다.
여기서는 connect 함수 내부에 익명 함수 형태로 선언합니다.
- CounterContainer1 생성 - src/containers/CounterContainer1.js
// 1. 더 간단하게 CounterContainer 만들기(CounterContainer1)
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';
const CounterContainer1 = ({ number, increase, decrease }) => {
// mapStateToProps, mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달됨
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
export default connect(
(state) => ({
number: state.counter.number,
}),
(dispatch) => ({
increase: () => dispatch(increase()),
decrease: () => dispatch(decrease()),
}),
)(CounterContainer1);
2. 더더 간단하게 CounterContainer 만들기
지금까지 했던 방법인, 컴포넌트에서 액션을 디스패치하기 위해 각 액션 생성 함수를 호출하고 dispatch로 감싸는 작업은 번거로울 수 있습니다. 특히 액션 함수의 개수가 많아지면 더 번거로울 수 있습니다.
리덕스에서 제공하는 bindActionCreators 유틸 함수를 사용하면 간편합니다.
- CounterContainer2 생성 - src/containers/CounterContainer2.js
// 2. 더더 간단하게 CounterContainer 만들기(CounterContainer2)
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';
const CounterContainer2 = ({ number, increase, decrease }) => {
// mapStateToProps, mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달됨
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
export default connect(
(state) => ({
number: state.counter.number,
}),
(dispatch) =>
bindActionCreators(
{
increase,
decrease,
},
dispatch,
),
)(CounterContainer2);
3. 더더더 간단하게 CounterContainer 만들기
2번에서 사용한 mapDispatchToProps 작업을 자동으로 하게끔 만드는 방법입니다.
mapDispatchToProps에 해당하는 파라미터를 함수 형태가 아닌 액션 생성 함수로 이루어진 객체 형태로 넣어주면 됩니다.
두 번째 파라미터를 아예 객체 형태로 넣어 주면 connect 함수가 내부적으로 bindActionCreators 작업을 대신 해줍니다.
- CounterContainer3 생성 - src/containers/CounterContainer3.js
// 3. 더더더 간단하게 CounterContainer 만들기(CounterContainer3)
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';
const CounterContainer3 = ({ number, increase, decrease }) => {
// mapStateToProps, mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달됨
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
export default connect(
(state) => ({
number: state.counter.number,
}),
{
increase,
decrease,
},
)(CounterContainer3);
5-2. TodosContainer
Todos 컴포넌트를 위한 컨테이너인 TodosContainer를 작성해봅시다.
connect함수와 CounterContainer3 생성에서 쓰인 방법으로 코드를 작성하겠습니다.
- TodosContainer 생성 - src/containers/TodosContainer.js
import { connect } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import Todos from '../components/Todos';
// 이전에 todos 모듈에서 작성했던 액션 생성 함수와 상태 안에 있던 값을 컴포넌트의 props로 전달해주었음
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를 분리하여
// state.todos.input 대신 todos.input을 사용
({ todos }) => ({
input: todos.input,
todos: todos.todos,
}),
{
// 두 번째 파라미터를 아예 객체 형태로 넣어주어 connect 함수가 내부적으로 bindActionCreators 작업을 대신 하도록 함
changeInput,
insert,
toggle,
remove,
},
)(TodosContainer);
TodosContainer 컨테이너 컴포넌트 생성 후 App 컴포넌트에 작성된 Todos 컴포넌트를 TodosContainer로 변경해줍니다.
- TodosContainer 생성 - src/App.js
import CounterContainer from './containers/CounterContainer';
import TodosContainer from './containers/TodosContainer';
const App = () => {
return (
<div>
{/* <Counter number={0} /> */}
<CounterContainer /> {/* Counter을 CounterContainer로 교체 */}
<hr />
{/* <Todos /> */}
<TodosContainer /> {/* Todos를 TodosContainer로 교체 */}
</div>
);
};
export default App;
위에서 작성했던 Todos 컴포넌트를 아래와 같이 수정합니다.
Todos 컴포넌트에서 받아 온 props를 TodosContainer에서 사용하도록 수정하는 작업입니다.
- Todos 컴포넌트 수정 - src/Todos.js
// Todos.js: 해야 할 일을 추가하고, 체크하고, 삭제할 수 있는 할 일 목록 컴포넌트
// TodoItem 컴포넌트: 이미 저장된 각각의 투두 컴포넌트
const TodoItem = ({ todo, onToggle, onRemove }) => {
return (
<div>
<input
type="checkbox"
onClick={() => onToggle(todo.id)}
checked={todo.done}
readOnly={true}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => onRemove(todo.id)}>삭제</button>
</div>
);
};
// Todos 컴포넌트: 반환할 전체 Todo 묶음
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 value={input} onChange={onChange} />
<button type="submit">등록</button>
</form>
<div>
{todos.map((todo) => (
<TodoItem
todo={todo}
key={todo.id}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</div>
</div>
);
};
export default Todos;
추가, 삭제, 체크박스 등 모든 동작이 잘 되는 것을 확인할 수 있습니다.
글이 너무 길어져서, 리덕스 더 편하게 사용하기는 다음 포스팅에서 다뤄보도록 하겠습니다.
'Study > React' 카테고리의 다른 글
17 - 추가. Redux 사용하기(1) - 앱 상태 관리 (2) | 2022.08.10 |
---|---|
16. Redux(리덕스) 기본 (0) | 2022.07.31 |
15. Context API (0) | 2022.07.30 |
14. 뉴스 뷰어 만들기(with. newsapi) (0) | 2022.07.27 |
13. Router(라우터) & SPA(Single Page Application) (0) | 2022.07.24 |