리덕스는 가장 많이 사용하는 리액트 상태 관리 라이브러리다.
리덕스를 사용하면 컴포넌트의 상태 업데이트 관련 로직을 다른 파일로 분리시켜서 더욱 효율적으로 관리할 수 있다. 또한, 컴포넌트끼리 똑같은 상태를 공유해야 할 때도 여러 컴포넌트를 거치지 않고 손쉽게 상태 값을 전달하거나 업데이트할 수 있다.
상태에 어떠한 변화가 필요하면 액션(action)이란 것이 발생합니다. 이는 하나의 객체로 표현되는데요. 액션 객체는 다음과 같은 형식으로 이루어져 있습니다.
{
type: 'TOGGLE_VALUE'
}
액션 객체는 type 필드를 반드시 가지고 있어야 합니다. 이 값을 액션의 이름이라고 생각하면 됩니다. 그리고 그 외의 값들은 나중에 상태 업데이트를 할 때 참고해야 할 값이며, 작성자 마음대로 넣을 수 있습니다.
//예시
{
type: 'ADD_TODO',
data: {
id: 1,
text: '리덕스 배우기'
}
}
{
type: 'CHANGE_INPUT',
text: '안녕하세요'
}
액션 객체를 만들어주는 함수
function addTodo(data) {
return {
type: 'ADD_TODO',
data
};
}
// 화살표 함수로도 만들 수 있습니다.
const changeInput = text => ({
type: 'CHANGE_INPUT',
text
});
리듀서(reducer)는 변화를 일으키는 함수
액션을 만들어서 발생시키면 리듀서가 현재 상태와 전달받은 액션 객체를 파라미터로 받아 옵니다. 그리고 두 값을 참고하여 새로운 상태를 만들어서 반환해 줍니다.
const initialState = {
counter: 1
};
function reducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return {
counter: state.counter + 1
};
default:
return state;
}
}
프로젝트에 리덕스를 적용하기 위해 스토어(store)를 만듭니다. 한 개의 프로젝트는 단 하나의 스토어만 가질 수 있습니다. 스토어 안에는 현재 애플리케이션 상태와 리듀서가 들어가 있으며, 그 외에도 몇 가지 중요한 내장 함수를 지닙니다.
디스패치(dispatch)는 스토어의 내장 함수 중 하나입니다. 디스패치는 ‘액션을 발생시키는 것’이라고 이해하면 됩니다. 이 함수는 dispatch(action)과 같은 형태로 액션 객체를 파라미터로 넣어서 호출합니다.
이 함수가 호출되면 스토어는 리듀서 함수를 실행시켜서 새로운 상태를 만들어 줍니다.
구독(subscribe)도 스토어의 내장 함수 중 하나입니다. subscribe 함수 안에 리스너 함수를 파라미터로 넣어서 호출해 주면, 이 리스너 함수가 액션이 디스패치되어 상태가 업데이트될 때마다 호출됩니다.
const listener = () => {
console.log('상태가 업데이트됨');
}
const unsubscribe = store.subscribe(listener);
unsubscribe(); // 추후 구독을 비활성화할 때 함수를 호출
리덕스는 리액트에 종속되는 라이브러리가 아닙니다. 리액트에서 사용하려고 만들어졌지만 실제로 다른 UI 라이브러리/프레임워크와 함께 사용할 수도 있습니다(예: angular-redux, ember redux, Vue에서도 사용할 수 있지만, Vue에서는 리덕스와 유사한 vuex를 주로 사용합니다).
리덕스는 바닐라(vanilla) 자바스크립트와 함께 사용할 수도 있습니다. 바닐라 자바스크립트는 라이브러리나 프레임워크 없이 사용하는 순수 자바스크립트 그 자체를 의미합니다.
이번에는 바닐라 자바스크립트 환경에서 리덕스를 사용하여 리덕스의 핵심 기능과 작동 원리를 이해해 보겠습니다
parcel-bundler를 설치
$ yarn global add parcel-bundler
# yarn global이 잘 설치되지 않는다면 npm install -g parcel-bundler를 해 보세요.
프로젝트 디렉터리를 생성한 후 package.json 파일을 생성
$ mkdir vanilla-redux
$ cd vanilla-redux
# package.json 파일을 생성합니다.
$ yarn init -y
에디터로 해당 디렉터리를 열어서 index.html과 index.js 파일 만들기
#index.html
<html>
<body>
<div>바닐라 자바스크립트</div>
<script src="./index.js"></script>
</body>
</html>
#index.js
console.log('hello parcel');
개발용 서버 실행
$ parcel index.html
Server running at http://localhost:1234
Built in 548ms.
리덕스 모듈 설치
$ yarn add redux
index.css
.toggle {
border: 2px solid black;
width: 64px;
height: 64px;
border-radius: 32px;
box-sizing: border-box;
}
.toggle.active {
background: yellow;
}
index.html
<html>
<head>
<link rel="stylesheet" type="text/css" href="index.css" />
</head>
<body>
<div class="toggle"></div>
<hr />
<h1>0</h1>
<button id="increase">+1</button>
<button id="decrease">-1</button>
<script src="./index.js"></script>
</body>
</html>
index.js(기존 코드 지우고 사용)
const divToggle = document.querySelector('.toggle');
const counter = document.querySelector('h1');
const btnIncrease = document.querySelector('#increase');
const btnDecrease = document.querySelector('#decrease');
index.js
const divToggle = document.querySelector('.toggle');
const counter = document.querySelector('h1');
const btnIncrease = document.querySelector('#increase');
const btnDecrease = document.querySelector('#decrease');
const TOGGLE_SWITCH = 'TOGGLE_SWITCH';
const INCREASE = 'INCREASE';
const DECREASE = 'DECREASE';
const toggleSwitch = () => ({ type: TOGGLE_SWITCH });
const increase = difference => ({ type: INCREASE, difference });
const decrease = () => ({ type: DECREASE });
const initialState = {
toggle: false,
counter: 0
};
index.js
(...)
// state가 undefined일 때는 initialState를 기본값으로 사용
function reducer(state = initialState, action) {
// action.type에 따라 다른 작업을 처리함
switch (action.type) {
case TOGGLE_SWITCH:
return {
...state, // 불변성 유지를 해 주어야 합니다.
toggle: !state.toggle
};
case INCREASE:
return {
...state,
counter: state.counter + action.difference
};
case DECREASE:
return {
...state,
counter: state.counter - 1
};
default:
return state;
}
}
스토어를 만들 때는 createStore 함수를 사용합니다. 이 함수를 사용하려면 코드 상단에 import 구문을 넣어 리덕스에서 해당 함수를 불러와야 하고, 함수의 파라미터에는 리듀서 함수를 넣어 주어야 합니다.
import { createStore } from 'redux';
(...)
const store = createStore(reducer);
이제 스토어를 생성했으니, 스토어 내장 함수들을 사용해 줄 차례입니다.
render 함수 만들기
render라는 함수를 작성해 보겠습니다. 이 함수는 상태가 업데이트될 때마다 호출되며, 리액트의 render 함수와는 다르게 이미 html을 사용하여 만들어진 UI의 속성을 상태에 따라 변경해 줍니다.
index.js
(...)
const store = createStore(reducer);
const render = () => {
const state = store.getState(); // 현재 상태를 불러옵니다.
// 토글 처리
if (state.toggle) {
divToggle.classList.add('active');
} else {
divToggle.classList.remove('active');
}
// 카운터 처리
counter.innerText = state.counter;
};
render();
구독하기
예시 코드
const listener = () => {
console.log('상태가 업데이트됨');
}
const unsubscribe = store.subscribe(listener);
unsubscribe(); // 추후 구독을 비활성화할 때 함수를 호출
이번 프로젝트에서는 subscribe 함수를 직접 사용하지만, 추후 리액트 프로젝트에서 리덕스를 사용할 때는 이 함수를 직접 사용하지 않을 것입니다. 왜냐하면, 컴포넌트에서 리덕스 상태를 조회하는 과정에서 react-redux라는 라이브러리가 이 작업을 대신해 주기 때문입니다.
액션 발생시키기
액션을 발생시키는 것을 디스패치라고 합니다. 디스패치를 할 때는 스토어의 내장 함수 dispatch를 사용합니다. 파라미터는 액션 객체를 넣어 주면 됩니다.
다음과 같이 각 DOM 요소에 클릭 이벤트를 설정하세요. 이벤트 함수 내부에서는 dispatch 함수를 사용하여 액션을 스토어에게 전달해 주겠습니다.
(...)
divToggle.onclick = () => {
store.dispatch(toggleSwitch());
};
btnIncrease.onclick = () => {
store.dispatch(increase(1));
};
btnDecrease.onclick = () => {
store.dispatch(decrease());
};
1. 단일 스토어
하나의 애플리케이션 안에는 하나의 스토어가 들어 있습니다. 사실 여러 개의 스토어를 사용하는 것이 완전히 불가능하지는 않습니다. 특정 업데이트가 너무 빈번하게 일어나거나 애플리케이션의 특정 부분을 완전히 분리시킬 때 여러 개의 스토어를 만들 수도 있지만, 상태 관리가 복잡해질 수 있으므로 권장하지 않습니다.
2. 읽기 전용 상태
리덕스 상태는 읽기 전용입니다. 기존에 리액트에서 setState를 사용하여 state를 업데이트할 때도 객체나 배열을 업데이트하는 과정에서 불변성을 지켜 주기 위해 spread 연산자를 사용하거나 immer와 같은 불변성 관리 라이브러리를 사용했지요? 리덕스도 마찬가지입니다. 상태를 업데이트할 때 기존의 객체는 건드리지 않고 새로운 객체를 생성해 주어야 합니다.
리덕스에서 불변성을 유지해야 하는 이유는 내부적으로 데이터가 변경되는 것을 감지하기 위해 얕은 비교(shallow equality) 검사를 하기 때문입니다. 객체의 변화를 감지할 때 객체의 깊숙한 안쪽까지 비교하는 것이 아니라 겉핥기 식으로 비교하여 좋은 성능을 유지할 수 있는 것이죠.
3. 리듀서는 순수한 함수
변화를 일으키는 리듀서 함수는 순수한 함수여야 합니다. 순수한 함수는 다음 조건을 만족합니다.
• 리듀서 함수는 이전 상태와 액션 객체를 파라미터로 받습니다.
• 파라미터 외의 값에는 의존하면 안 됩니다.
• 이전 상태는 절대로 건드리지 않고, 변화를 준 새로운 상태 객체를 만들어서 반환합니다.
• 똑같은 파라미터로 호출된 리듀서 함수는 언제나 똑같은 결과 값을 반환해야 합니다.
리듀서를 작성할 때는 위 네 가지 사항을 주의해 주세요. 예를 들어 리듀서 함수 내부에서 랜덤 값을 만들거나, Date 함수를 사용하여 현재 시간을 가져오거나, 네트워크 요청을 한다면, 파라미터가 같아도 다른 결과를 만들어 낼 수 있기 때문에 사용하면 안 됩니다. 이러한 작업은 리듀서 함수 바깥에서 처리해 주어야 합니다. 액션을 만드는 과정에서 처리해도 되고, 추후 배울 리덕스 미들웨어에서 처리해도 됩니다. 주로 네트워크 요청과 같은 비동기 작업은 미들웨어를 통해 관리합니다.
리액트 애플리케이션에서 리덕스를 사용하면, 상태 업데이트에 관한 로직을 모듈로 따로 분리하여 컴포넌트 파일과 별개로 관리할 수 있으므로 코드를 유지 보수하는 데 도움이 됩니다. 또한, 여러 컴포넌트에서 동일한 상태를 공유해야 할 때 매우 유용하며, 실제 업데이트가 필요한 컴포넌트만 리렌더링되도록 쉽게 최적화해 줄 수도 있습니다.
리액트 애플리케이션에서 리덕스를 사용할 때는 store 인스턴스를 직접 사용하기보다는 주로 react-redux라는 라이브러리에서 제공하는 유틸 함수(connect)와 컴포넌트(Provider)를 사용하여 리덕스 관련 작업을 처리합니다. 먼저 create-react-app을 사용하여 새로운 리액트 프로젝트를 생성하세요.
$ yarn create react-app react-redux-tutorial
생성한 프로젝트 디렉터리에 yarn 명령어를 사용하여 리덕스와 react-redux 라이브러리를 설치하세요.
$ cd react-redux-tutorial
$ yarn add redux react-redux
프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하여 코드의 재사용성을 높이고, UI를 작성할 때 좀 더 집중할 수 있습니다. 프레젠테이셔널 컴포넌트란 주로 상태 관리가 이루어지지 않고, 그저 props를 받아 와서 화면에 UI를 보여 주기만 하는 컴포넌트를 말합니다. 이와 달리 컨테이너 컴포넌트는 리덕스와 연동되어 있는 컴포넌트로, 리덕스로부터 상태를 받아 오기도 하고 리덕스 스토어에 액션을 디스패치하기도 합니다. UI에 관련된 프레젠테이셔널 컴포넌트는 src/components 경로에 저장하고, 리덕스와 연동된 컨테이너 컴포넌트는 src/containers 컴포넌트에 작성합니다.
숫자를 더하고 뺄 수 있는 카운터 컴포넌트를 만들어 보겠습니다. components 디렉터리를 생성한 뒤, 그 안에 Counter 컴포넌트를 작성하세요.
import React from 'react';
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;
이제 이 컴포넌트를 App 컴포넌트에서 렌더링합니다.
import React from 'react';
import Counter from './components/Counter';
const App = () => {
return (
<div>
<Counter number={0} />
</div>
);
};
export default App;
yarn start 명령어를 입력해서 개발 서버를 실행해 카운터 컴포넌트가 나타나는지 확인해보세요.
이번에는 해야 할 일을 추가하고, 체크하고, 삭제할 수 있는 할 일 목록 컴포넌트를 만들어보겠습니다. components 디렉터리에 Todos 컴포넌트를 다음과 같이 작성하세요.
import React from 'react';
const TodoItem = ({ todo, onToggle, onRemove }) => {
return (
<div>
<input type="checkbox" />
<span>예제 텍스트</span>
<button>삭제</button>
</div>
);
};
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 />
</div>
</div>
);
};
export default Todos;
파일 하나에 두 컴포넌트를 선언했습니다. 컴포넌트를 다 만들었으니 App 컴포넌트에서 카운터 아래에 렌더링해 주세요.
import React from 'react';
import Counter from './components/Counter';
import Todos from './components/Todos';
const App = () => {
return (
<div>
<Counter number={0} />
<hr />
<Todos />
</div>
);
};
export default App;
리덕스를 사용할 때는 액션 타입, 액션 생성 함수, 리듀서 코드를 작성해야 하는데요. 이 코드들을 각각 다른 파일에 작성하는 방법도 있고, 기능별로 묶어서 파일 하나에 작성하는 방법도 있습니다. 가장 일반적인 구조로 actions, constants, reducers라는 세 개의 디렉터리를 만들고 그 안에 기능별로 파일을 하나씩 만드는 방식입니다. 코드를 종류에 따라 다른 파일에 작성하여 정리할 수 있어서 편리하지만, 새로운 액션을 만들 때마다 세 종류의 파일을 모두 수정해야 하기 때문에 불편하기도 합니다. 이 방식은 리덕스 공식 문서에서도 사용되므로 가장 기본적이라 할 수 있지만, 사람에 따라서는 불편할 수도 있는 구조입니다. 이러한 일반적인 구조로 리덕스를 사용하다가 불편함을 느낀 개발자들이 자주 사용하는 Ducks 패턴은 액션 타입, 액션 생성 함수, 리듀서 함수를 기능별로 파일 하나에 몰아서 다 작성하는 방식입니다.
Ducks 패턴을 사용하여 counter 모듈을 작성해봅시다. modules 디렉터리를 생성하고 그 안에 counter.js 파일을 다음과 같이 작성하세요.
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
먼저 액션 타입을 대문자로 정의하고, 문자열 내용은 '모듈 이름/액션 이름'과 같은 형태로 작성합니다. 문자열 안에 모듈 이름을 넣음으로써, 나중에 프로젝트가 커졌을 때 액션의 이름이 충돌되지 않게 해 줍니다. 액션 타입을 정의했으니 이제 액션 생성 함수를 추가해주세요.
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
앞부분에 export 키워드가 들어간다는 것을 주의해주세요. 이렇게 함으로써 추후 이 함수를 다른 파일에서 불러와 사용할 수 있습니다. 이제 counter 모듈의 초기 상태와 리듀서 함수를 만들어보겠습니다.
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
const initialState = {
number: 0
};
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;
다음으로 todos 모듈을 만들어봅시다. modules 디렉터리에 todos.js 파일을 생성하세요. 이전과 마찬가지로 가장 먼저 액션 타입을 정의한 다음, 액션 생성 함수를 만듭니다. 조금 전과 달리 이번에는 액션 생성 함수에서 파라미터가 필요합니다. 전달받은 파라미터는 액션 객체 안에 추가 필드로 들어가게 됩니다.
const CHANGE_INPUT = 'todos/CHANGE_INPUT'; // 인풋 값을 변경함
const INSERT = 'todos/INSERT'; // 새로운 todo를 등록함
const TOGGLE = 'todos/TOGGLE'; // todo를 체크/체크 해제함
const REMOVE = 'todos/REMOVE'; // todo를 제거함
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
});
이제 모듈의 초기 상태와 리듀서 함수를 작성합니다. 객체에 한 개 이상의 값이 들어가므로 불변성을 유지해 주어야 하는 까다로움이 있습니다. 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;
지금까지 여러 개의 리듀서를 만들었는데, 나중에 createStore 함수를 사용하여 스토어를 만들 때는 리듀서를 하나만 사용해야 합니다. 그렇기 때문에 기존에 만들었던 리듀서를 하나로 합쳐 주어야 하는데요. 이 작업은 리덕스에서 제공하는 combineReducers라는 유틸 함수를 사용하면 쉽게 처리할 수 있습니다.
modules 디렉터리에 index.js 파일을 만들고, 그 안에 다음과 같은 코드를 작성하세요.
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';
const rootReducer = combineReducers({
counter,
todos,
});
export default rootReducer;
스토어를 만들고 리액트 애플리케이션에 리덕스를 적용하는 작업은 src 디렉터리의 index.js에서 이루어집니다.
가장 먼저 스토어를 생성합니다.
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import rootReducer from './modules';
const store = createStore(rootReducer);
ReactDOM.render(<App />, document.getElementById('root'));
serviceWorker.unregister();
리액트 컴포넌트에서 스토어를 사용할 수 있도록 App 컴포넌트를 react-redux에서 제공하는 Provider 컴포넌트로 감싸 줍니다. 이 컴포넌트를 사용할 때는 store를 props로 전달해 주어야 합니다.
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import rootReducer from './modules';
const store = createStore(rootReducer);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'),
);
serviceWorker.unregister();
Redux DevTools는 리덕스 개발자 도구이며, 크롬 확장 프로그램으로 설치하여 사용할 수 있습니다. 크롬 웹 스토어(https://chrome.google.com/webstore/)에서 Redux DevTools를 검색하여 설치해 주세요. 설치하고 나면 리덕스 스토어를 만드는 과정에서 다음과 같이 적용해 줄 수 있습니다.
const store = createStore(
rootReducer, /* preloadedState, */
window._ _REDUX_DEVTOOLS_EXTENSION_ _ && window._ _REDUX_DEVTOOLS_EXTENSION_ _()
);
하지만 패키지를 설치하여 적용하면 코드가 훨씬 깔끔해집니다. (패키지를 설치하여 사용한다고 해도 크롬 확장 프로그램은 설치해야 합니다)
$ yarn add redux-devtools-extension
적용하면 다음과 같이 코드가 완성됩니다.
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import rootReducer from './modules';
const store = createStore(rootReducer, composeWithDevTools());
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'),
);
serviceWorker.unregister();
브라우저에서 크롬 개발자 도구를 실행한 후 Redux 탭을 열어보세요. 리덕스 개발자 도구가 잘 나타났나요? 리덕스 개발자 도구 안의 State 버튼을 눌러 현재 리덕스 스토어 내부의 상태가 잘 보이는지 확인해보세요.
이제는 컴포넌트에서 리덕스 스토어에 접근하여 원하는 상태를 받아 오고, 또 액션도 디스패치해 줄 차례입니다.
src 디렉터리에 containers 디렉터리를 생성하고, 그 안에 CounterContainer 컴포넌트를 만드세요.
import React from 'react';
import Counter from '../components/Counter';
const CounterContainer = () => {
return <Counter />;
};
export default CounterContainer;
위 컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야 합니다. 이 함수는 다음과 같이 사용합니다.
connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)
여기서 mapStateToProps는 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수이고, mapDispatchToProps는 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수입니다. 이렇게 connect 함수를 호출하고 나면 또 다른 함수를 반환합니다. 반환된 함수에 컴포넌트를 파라미터로 넣어 주면 리덕스와 연동된 컴포넌트가 만들어집니다. 위 코드를 더 쉽게 풀면 다음과 같은 형태입니다.
const makeContainer = connect(mapStateToProps, mapDispatchToProps)
makeContainer(타깃 컴포넌트)
connect를 사용해서 아래와 같이 CounterContainer를 완성하세요.
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
const CounterContainer = ({ number, increase, decrease }) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
const mapStateToProps = state => ({
number: state.counter.number,
});
const mapDispatchToProps = dispatch => ({
// 임시 함수
increase: () => {
console.log('increase');
},
decrease: () => {
console.log('decrease');
},
});
export default connect(
mapStateToProps,
mapDispatchToProps,
)(CounterContainer);
mapStateToProps와 mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달됩니다. mapStateToProps는 state를 파라미터로 받아 오며, 이 값은 현재 스토어가 지니고 있는 상태를 가리킵니다. mapDispatchToProps의 경우 store의 내장 함수 dispatch를 파라미터로 받아 옵니다. 현재 mapDispatchToProps에서는 진행 절차를 설명하기 위해 임시로 console.log를 사용하고 있습니다.
추가로, App에서 Counter를 CounterContainer로 교체합니다.
import React from 'react';
import Todos from './components/Todos';
import CounterContainer from './containers/CounterContainer';
const App = () => {
return (
<div>
<CounterContainer />
<hr />
<Todos />
</div>
);
};
export default App;
브라우저에서 +1, -1 버튼을 눌러보면서 콘솔에 increase와 decrease가 찍히는 것을 확인할 수 있을 것입니다.이제 console.log 대신 액션 생성 함수를 불러와서 액션 객체를 만들고 디스패치해주겠습니다.
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} />
);
};
const mapStateToProps = state => ({
number: state.counter.number,
});
const mapDispatchToProps = dispatch => ({
increase: () => {
dispatch(increase());
},
decrease: () => {
dispatch(decrease());
},
});
export default connect(
mapStateToProps,
mapDispatchToProps,
)(CounterContainer);
이제 +1, -1 버튼을 누르면 숫자가 바뀌는 것을 확인할 수 있을 것입니다.
mapDispatchToProps에 해당하는 파라미터를 함수 형태가 아닌 액션 생성 함수로 이루어진 객체 형태로 넣어 주는 더 간단한 방법도 있습니다.
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} />
);
};
export default connect(
state => ({
number: state.counter.number,
}),
{
increase,
decrease,
},
)(CounterContainer);
위와 같이 두 번째 파라미터를 아예 객체 형태로 넣어 주면 connect 함수가 내부적으로 bindActionCreators 작업을 대신해 줍니다.
이번에는 Todos 컴포넌트를 위한 컨테이너인 TodosContainer를 작성해 보겠습니다. CounterContainer를 만들 때 배웠던 connect 함수를 사용하고, mapDispatchToProps를 짧고 간단하게 쓰는 방법을 적용해서 코드를 작성해 보세요.
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를 분리하여
// state.todos.input 대신 todos.input을 사용
({ todos }) => ({
input: todos.input,
todos: todos.todos,
}),
{
changeInput,
insert,
toggle,
remove,
},
)(TodosContainer);
컨테이너 컴포넌트를 다 만든 후에는 App 컴포넌트에서 보여 주던 Todos 컴포넌트를 TodosContainer 컴포넌트로 교체하세요.
import React from 'react';
import CounterContainer from './containers/CounterContainer';
import TodosContainer from './containers/TodosContainer';
const App = () => {
return (
<div>
<CounterContainer />
<hr />
<TodosContainer />
</div>
);
};
export default App;
그다음에는 Todos 컴포넌트에서 받아 온 props를 사용하도록 구현해 보세요.
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={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => onRemove(todo.id)}>삭제</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 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;
이제 모든 작업이 끝났습니다. 브라우저에서 할 일 목록 기능이 잘 작동하는지 확인해 보세요. 일정을 새로 추가해 보고, 체크 박스도 눌러 보고, 삭제도 해 보세요.
액션 생성 함수, 리듀서를 작성할 때 redux-actions라는 라이브러리와 이전에 배웠던 immer 라이브러리를 활용하면 리덕스를 훨씬 편하게 사용할 수 있습니다.
redux-actions를 사용하면 액션 생성 함수를 더 짧은 코드로 작성할 수 있습니다. 그리고 리듀서를 작성할 때도 switch/case문이 아닌 handleActions라는 함수를 사용하여 각 액션마다 업데이트 함수를 설정하는 형식으로 작성해 줄 수 있습니다. 우선 라이브러리를 설치해 주세요.
$ yarn add redux-actions
counter 모듈에 적용하기 위해서 counter 모듈에 작성된 액션 생성 함수를 createAction이란 함수를 사용하여 만들어 주겠습니다. createAction을 사용하면 매번 객체를 직접 만들어 줄 필요 없이 더욱 간단하게 액션 생성 함수를 선언할 수 있습니다.
import { createAction } from 'redux-actions';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
(...)
리듀서 함수도 더 간단하고 가독성 높게 작성해볼 수 있습니다. handleAction 함수를 사용하면 됩니다.
import { createAction, handleActions } from 'redux-actions';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
const initialState = {
number: 0,
};
const counter = handleActions(
{
[INCREASE]: (state, action) => ({ number: state.number + 1 }),
[DECREASE]: (state, action) => ({ number: state.number - 1 }),
},
initialState,
);
export default counter;
handleActions 함수의 첫 번째 파라미터에는 각 액션에 대한 업데이트 함수를 넣어 주고, 두 번째 파라미터에는 초기 상태를 넣어 줍니다. 코드가 훨씬 짧아지고 가독성이 높아진 것을 확인할 수 있습니다.
리듀서에서 상태를 업데이트할 때는 불변성을 지켜야 하기 때문에 앞에서는 spread 연산자(…)와 배열의 내장 함수를 활용했습니다. 그러나 모듈의 상태가 복잡해질수록 불변성을 지키기가 까다로워집니다. 따라서 모듈의 상태를 설계할 때는 객체의 깊이가 너무 깊어지지 않도록 주의해야 합니다. 객체의 깊이가 깊지 않을수록 추후 불변성을 지켜 가면서 값을 업데이트할 때 수월합니다. 하지만 상황에 따라 상태 값들을 하나의 객체 안에 묶어서 넣는 것이 코드의 가독성을 높이는 데 유리하며, 나중에 컴포넌트에 리덕스를 연동할 때도 더욱 편합니다. 객체의 구조가 복잡해지거나 객체로 이루어진 배열을 다룰 경우, immer를 사용하면 훨씬 편리하게 상태를 관리할 수 있습니다.
1. 아래 코드의 액션 생성 함수를 함수 createAction를 사용하여 만들도록 수정하기
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
const initialState = {
number: 0
};
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;
import { createAction } from 'redux-actions';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
(...)
2. 1번 코드의 리듀서 함수를 handleAction 함수를 사용하여 작성해 더 간단하고 가독성 좋게 수정하기
import { createAction, handleActions } from 'redux-actions';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
const initialState = {
number: 0,
};
const counter = handleActions(
{
[INCREASE]: (state, action) => ({ number: state.number + 1 }),
[DECREASE]: (state, action) => ({ number: state.number - 1 }),
},
initialState,
);
export default counter;
출처 : 이정환, 『한 입 크기로 잘라먹는 리액트』, 프로그래밍인사이트(2023)
Corner React.js 1
Editor: dalpaeng4, nini, ssxbin
[React.js 1] project3. [감정 일기장] 만들기 (0) | 2024.01.12 |
---|---|
[React.js 1] 9장. 컴포넌트 트리에 데이터 공급하기 (1) | 2024.01.05 |
[React.js 1] 7-8장. useReducer와 상태관리 & 최적화 (0) | 2023.12.29 |
[React.js 1] p2. 할 일 관리 앱 만들기(2) (1) | 2023.12.22 |
[React.js 1] p2. 할 일 관리 앱 만들기 (0) | 2023.12.01 |