//액션 객체의 예시
{
type: 'TOGGLE_VALUE'
data: {
id: 1,
text: '리덕스 배우기'
}
}
function addTodo(data) {
return {
type: 'ADD_TODO',
data
};
}
//화살표 함수
const changeInput = text => ({
type: 'CHANGE_INPUT',
text
});
const initialState = {
counter: 1
};
funtion reducer(state = initialState, action) { //리듀서가 현재 상태와 액션 객체를 파라미터로 받아옴.
switch(action.type) {//액션 객체의 type에 따라
case INCREMENT : //증가라면
return {
counter: state.counter + 1 //counter의 값을 증가
};
default:
retrun state;
}
}
const listener = () => {
console.log('상태가 업데이트됨');
}
const unsubscribe = store.subscribe(listener); // 리스너 함수를 파라미터로 넣어서 호출
//이 리스너 함수가 액션이 디스패치되어 상태가 업데이트될 때마다 호출된다.
unsubscribe(); //추후 구독을 비활성화 할 때 함수를 호출
1. parcel-bundler 설치하기
$ yarn global add parcel-bundler
# yarn global 이 잘 설치되지 않는다면 npm install -g parcel-bundler를 해보기
2. 프로젝트 디렉터리 생성 후 package.json 파일 생성
$ mkdir vanilla-redux
$ cd vanilla-redux
#package.json 파일 생성
$ yarn init -y
3. index.html과 index.js 파일 만들기
//index.html
<html>
<body>
<div>바닐라 자바스크립트</div>
<script src="./index.js"></script>
</body>
</html>
console.log(`hello parcel`);
4. 다음 명령어를 실행 후 개발용 서버가 실행된다.
$ parcel index.html
Server running at http://localhost:1234 Built in 548ms.
5. http://localhost:1234 주소로 들어가기
6. yarn을 사용하여 리덕스 모듈 설치
$ 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;
}
<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');
액션은 프로젝트의 상태에 변화를 일으키는 것이다.
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
};
다시 한 번, 리듀서는 변화를 일으키는 함수이다. 파라미터로 state, action 값을 받아온다.
// 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 { createStore } from 'redux';
(...)
const store = createStore(reducer);
render 함수는 상태가 업데이트될 때마다 호출한다.
리액트의 render 함수와는 다르게 이미 html을 사용하여 만들어진 UI 속성을 상태에 따라 변경
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();
subscribe 함수의 파라미터로 함수 형태의 값 전달한다.
(...)
render();
store.subscribe(render);
액션을 발생시키는 것 = dispatch이다. 이 함수는 파라미터에 액션 객체를 넣어준다.
(...)
divToggle.onclick = () => {
store.dispatch(toggleSwitch());
};
btnIncrease.onclick = () => {
store.dispatch(increase(1));
};
btnDecrease.onclick = () => {
store.dispatch(decrease());
};
하나의 애플리케이션은 하나의 스토어만 가져야 된다.
특정 업데이트가 너무 빈번하게 일어나거나 애플리케이션의 특정 부분을 완전히 분리시킬 때 여러 개의 스토어를 만들 수 있지만 상태 관리가 복잡해질 수 있으므로 권장하지 않는다.
리덕스의 상태는 읽기 전용이다.
순수한 함수는 다음과 같은 조건을 만족한다,
리듀서 함수 내부에서 랜던 값을 만들거나, Date 함수를 사용해 현재 시간을 가져오거나, 네트워크 요청을 할 경우 파라미터가 같아도 다른 결과를 만들어 낼 수 있기 때문에 리듀서 작성시 사용하면 안된다. 이러한 작업은 액션을 만드는 과정, 리덕스 미들웨어 등 리듀서 함수 바깥에서 처리할 수 있다. 주로 네트워크 요청과 같은 비동기 작업은 미들웨어를 통해 관리g한다.
리듀서 코드는 다음과 같이 작성한다.
액션 타입과 액션 생성 함수를 작성 → 리듀서를 작성→ 스토어를 만들기
이번 프로젝트에서는 함수에서 스토어를 구독하는 작업을 직접 수행했지만, 다음 장에서는 react-redux라는 라이브러리를 사용하여 스토어의 상태가 업데이트될 때마다 컴포넌트를 리렌더링시켜 주도록 할 것이다.
리액트 애플리케이션에서 리덕스를 사용했을 때의 장점은 다음과 같다.
먼저, 새로운 리액트 프로젝트를 생성하고 리덕스와 react-redux 라이브러리도 설치한다.
$ yarn create react-app react-redux-tutorial
$ cd react-redux-tutorial
$ yarn add redux react-redux
Prettier를 적용하고 싶다면 디렉터리에 .prettierrc 파일을 작성한다.
{
"singleQuote": true,
"semi": true,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80
}
리액트 프로젝트에서 리덕스를 사용할 때 가장 많이 사용하는 패턴은 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하는 것이다. 이 패턴을 사용하면 코드의 재사용성도 높아지고, 관심사의 분리가 이루어져 UI를 작성할 때 좀 더 집중할 수 있다.
숫자를 더하고 뺄 수 있는 카운터 컴포넌트를 만들어보자.
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;
import React from 'react';
import Counter from './components/Counter';
const App = () => {
return (
<div>
<Counter number={0} />
</div>
);
};
export default App;
이번에는 해야 할 일을 추가하고, 체크하고, 삭제할 수 있는 할 일 목록 컴포넌트를 만들어 보자.
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.js
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;
이제 프로젝트에 리덕스를 사용해보자. 리덕스를 사용할 때는 액션 타입, 액션 생성 함수, 리듀서 코드를 작성해야 하는데 이들을 각각 다른 파일에 작성해도 되고, 기능별로 묶어서 작성해도 된다.
모듈이란 Ducks 패턴을 사용하여 액션 타입, 액션 생성 함수, 리듀서를 작성한 코드이다. 먼저 counter 모듈을 작성해보자.
① 액션 타입 정의하기
가장 먼저 해야 할 작업은 액션 타입을 정의하는 것이다. 액션 타입은 대문자로 정의하고, 문자열 내용은 '모듈 이름/액션 이름'과 같은 형태로 작성한다.
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
② 액션 생성 함수 만들기
액션 타입을 정의한 다음에는 액션 생성 함수를 만든다. 액션 함수 앞에는 export라는 키워드가 들어간다.
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
③ 초기 상태 및 리듀서 함수 만들기
이제 counter 모듈의 초기 상태와 리듀서 함수를 만들어보자.
(...)
const initialState = { //초기 상태 number값 설정
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;
※ export와 export default 차이점
export는 여러 개를 내보낼 수 있지만 export default는 단 한 개만 내보낼 수 있다, (import 경우도 마찬가지임)
① 액션 타입 정의하기
const CHANGE_INPUT = 'todos/CHANGE_INPUT'; // 인풋 값을 변경함
const INSERT = 'todos/INSERT'; // 새로운 todo를 등록함
const TOGGLE = 'todos/TOGGLE'; // todo를 체크/체크 해제함
const REMOVE = 'todos/REMOVE'; // todo를 제거함
② 액션 생성 함수 만들기
이번에는 액션 생성 함수에 파라미터가 필요하다. 전달받은 파라미터는 액션 객체 안에 추가 필드로 들어간다.
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
});
③ 초기 상태 및 리듀서 함수 만들기
마지막으로 모듈의 초기 상태와 리듀서 함수를 작성하자. 배열에 변화를 줄 때는 배열 내장 함수를 사용하여 구현하면 된다.
(...)
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라는 유틸 함수를 사용하여 기존에 만들었던 리듀서를 하나로 합쳐보자.
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 { Provider } from 'react-redux';
const store = createStore(rootReducer);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'),
);
serviceWorker.unregister();
Redux DevTools는 리덕스 개발자 도구이며, 크롬 확장 프로그램으로 설치하여 사용할 수 있다. 크롬 웹 스토어(https://chrome.google.com/webstore/) 에서 설치하면 된다.
또한, 패키지를 설치하여 코드를 깔끔하게 작성할 수도 있다.
$ yarn add redux-devtools-extension
패키지를 설치한 후 다음과 같이 적용해보자.
(...)
import { composeWithDevTools } from 'redux-devtools-extension';
const store = createStore(rootReducer, composeWithDevTools());
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'),
);
serviceWorker.unregister();
그러면 이제 브라우저에서 크롬 개발자 도구를 실행한 후, 리덕스 개발자 도구 안의 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(타깃 컴포넌트)
이제 CounterContainer 컴포넌트에서 connect를 사용해 보자.
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 CounterContainer from './containers/CounterContainer';
(...)
<CounterContainer />
(...)
브라우저를 열어서 +1, -1 버튼을 눌러보고 콘솔에 increase와 decrease가 찍히는지 확인한다.
이번에는 console.log 대신 액션 생성 함수를 불러와서 액션 객체를 만들고 디스패치해 주겠다.
(...)
import { increase, decrease } from '../modules/counter';
(...)
const mapDispatchToProps = dispatch => ({
increase: () => {
dispatch(increase());
},
decrease: () => {
dispatch(decrease());
},
});
(...)
다시 +1, -1 버튼을 눌러 숫자가 바뀌는지 확인한다. 리덕스 개발자 도구도 확인해 본다.
connect 함수를 사용할 때는 일반적으로 위 코드와 같이 mapStateToProps와 mapDispatchToProps를 미리 선언해 놓고 사용한다. 하지만 connect 함수 내부에 익명 함수 형태로 선언해도 문제가 되지 않는다. 어떻게 보면 코드가 더 깔끔해지기도 한다.
취향에 따라 다음과 같이 작성해도 된다.
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,
}),
dispatch => ({
increase: () => dispatch(increase()),
decrease: () => dispatch(decrease()),
}),
)(CounterContainer);
위 코드에서는 액션 생성 함수를 호출하여 디스패치하는 코드가 한 줄이기 때문에 불필요한 코드 블록을 생략해 주었다. 다음 두 줄의 코드는 작동 방식이 완전히 같다.
increase: () => dispatch(increase()),
increase: () => { return dispatch(increase()) },
컴포넌트에서 액션을 디스패치하기 위해 각 액션 생성 함수를 호출하고 dispatch로 감싸는 작업이 조금 번거로울 수도 있다. 이와 같은 경우에는 리덕스에서 제공하는 bindActionCreators 유틸 함수를 사용하면 간편하다.
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} />
);
};
export default connect(
state => ({
number: state.counter.number,
}),
dispatch =>
bindActionCreators(
{
increase,
decrease,
},
dispatch,
),
)(CounterContainer);
브라우저를 열어서 조금 전과 똑같이 작동하는지 확인한다.
방금 작성한 방법보다 한 가지 더 편한 방법이 있다. 바로 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를 작성해 보겠다. 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);
이전에 todos 모듈에서 작성했던 액션 생성 함수와 상태 안에 있던 값을 컴포넌트의 props로 전달해 주었다.
컨테이너 컴포넌트를 다 만든 후에는 App 컴포넌트에서 보여 주던 Todos 컴포넌트를 TodosContainer 컴포넌트로 교체한다.
그 다음에는 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 모듈에 작성된 액션 생성 함수를 createAction이란 함수를 사용하여 만들어 주겠다.
import { createAction } from 'redux-actions';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
(...)
createAction을 사용하면 매번 객체를 직접 만들어 줄 필요 없이 더욱 간단하게 액션 생성 함수를 선언할 수 있다.
이번에는 리듀서 함수도 더 간단하고 가독성 높게 작성해 보겠다.
handleActions라는 함수를 사용한다.
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 함수의 첫 번째 파라미터에는 각 액션에 대한 업데이트 함수를 넣어 주고, 두 번째 파라미터에는 초기 상태를 넣어 준다. 코드가 훨씬 짧아지고 가독성이 높아진 것을 확인 할 수 있다.
똑같은 작업을 todos 모듈에도 적용해 보자. 먼저 액션 생성 함수를 교체해 줄 텐데, 조금 다른 점이 있다. 바로 각 액션 생성 함수에서 파라미터를 필요로 한다는 점이다.
createAction으로 액션을 만들면 액션에 필요한 추가 데이터는 payload라는 이름을 사용한다.
예를 들면 다음과 같다.
const MY_ACTION = 'sample/MY_ACTION';
const myAction = createAction(MY_ACTION);
const action = myAction('hello world');
/*
결과:
{ type: MY_ACTION, payload: 'hello world' }
*/
액션 생성 함수에서 받아 온 파라미터를 그대로 payload에 넣는 것이 아니라 변형을 주어서 넣고 싶다면, createAction의 두 번째 함수에 payload를 정의하는 함수를 따로 선언해서 넣어 주면 된다.
const MY_ACTION = 'sample/MY_ACTION';
const myAction = createAction(MY_ACTION, text => `${text}!`);
const action = myAction('hello world');
/*
결과:
{ type: MY_ACTION, payload: 'hello world!' }
*/
이제 todos 모듈의 액션 생성 함수를 다음과 같이 새로 작성해 준다.
import { createAction } from 'redux-actions';
const CHANGE_INPUT = 'todos/CHANGE_INPUT'; // 인풋 값을 변경함
const INSERT = 'todos/INSERT'; // 새로운 todo를 등록함
const TOGGLE = 'todos/TOGGLE'; // todo를 체크/체크 해제함
const REMOVE = 'todos/REMOVE'; // todo를 제거함
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);
(...)
insert의 경우 todo 객체를 액션 객체 안에 넣어 주어야 하기 때문에 두 번째 파라미터에 text를 넣으면 todo 객체가 반환되는 함수를 넣어 주었다.
나머지 함수에는 text => text 혹은 id => id와 같은 형태로 파라미터를 그대로 반환하는 함수를 넣었다. 이 작업이 필수는 아니다. 생략해도 똑같이 작동하지만, 여기서 이 함수를 넣어 줌으로써 코드를 보았을 때 이 액션 생성 함수의 파라미터로 어떤 값이 필요한지 쉽게 파악할 수 있다.
액션 생성 함수를 다 작성했으면 handleActions로 리듀서를 재작성해 보겠다. createAction으로 만든 액션 생성 함수는 파라미터로 받아 온 값을 객체 안에 넣을 때 원하는 이름으로 넣는 것이 아니라 action.id, action.todo와 같이 action.payload라는 이름을 공통적으로 넣어 주게 된다.
그렇기 때문에, 기존의 업데이트 로직에서도 모두 action.payload 값을 조회하여 업데이트하도록 구현해 주어야 한다.
액션 생성 함수는 액션에 필요한 추가 데이터를 모두 payload라는 이름으로 사용하기 때문에 action.id, action.todo를 조회하는 대신, 모두 공통적으로 action.payload 값을 조회하도록 리듀서를 구현해 주어야 한다.
import { createAction, handleActions } from 'redux-actions';
(...)
const todos = handleActions(
{
[CHANGE_INPUT]: (state, action) => ({ ...state, input: action.payload }),
[INSERT]: (state, action) => ({
...state,
todos: state.todos.concat(action.payload),
}),
[TOGGLE]: (state, action) => ({
...state,
todos: state.todos.map(todo =>
todo.id = = = action.payload ? { ...todo, done: !todo.done } : todo,
),
}),
[REMOVE]: (state, action) => ({
...state,
todos: state.todos.filter(todo => todo.id != = action.id),
}),
},
initialState,
);
export default todos;
모든 추가 데이터 값을 action.payload로 사용하기 때문에 나중에 리듀서 코드를 다시 볼 때 헷갈릴 수 있다. 객체 비구조화 할당 문법으로 action 값의 payload 이름을 새로 설정해 주면action.payload가 정확히 어떤 값을 의미하는지 더 쉽게 파악할 수 있다.
(...)
const todos = handleActions(
{
[CHANGE_INPUT]: (state, { payload: input }) => ({ ...state, input }),
[INSERT]: (state, { payload: todo }) => ({
...state,
todos: state.todos.concat(todo),
}),
[TOGGLE]: (state, { payload: id }) => ({
...state,
todos: state.todos.map(todo =>
todo.id = = = id ? { ...todo, done: !todo.done } : todo,
),
}),
[REMOVE]: (state, { payload: id }) => ({
...state,
todos: state.todos.filter(todo => todo.id != = id),
}),
},
initialState,
);
export default todos;
코드의 가독성이 더 높아진 것을 확인할 수 있다.
리듀서에서 상태를 업데이트할 때는 불변성을 지켜야 하기 때문에 앞에서는 spread 연산자(…)와 배열의 내장 함수를 활용했다. 그러나 모듈의 상태가 복잡해질수록 불변성을 지키기가 까다로워진다.
따라서 모듈의 상태를 설계할 때는 객체의 깊이가 너무 깊어지지 않도록 주의해야 한다. 깊은 객체와 깊지 않은 객체를 한번 비교해 보자.
const deepObject = {
modal: {
open: false,
content: {
title: '알림',
body: '성공적으로 처리되었습니다.',
buttons: {
confirm: '확인',
cancel: '취소',
},
},
},
waiting: false,
settings: {
theme: 'dark',
zoomLevel: 5,
},
};
const shallowObject = {
modal: {
open: false,
title: '알림',
body: '성공적으로 처리되었습니다.',
confirm: '확인',
cancel: '취소',
},
waiting: false,
theme: 'dark',
zoomLevel: 5
}
객체의 깊이가 깊지 않을수록 추후 불변성을 지켜 가면서 값을 업데이트할 때 수월하다. 하지만 상황에 따라 상태 값들을 하나의 객체 안에 묶어서 넣는 것이 코드의 가독성을 높이는 데 유리하며, 나중에 컴포넌트에 리덕스를 연동할 때도 더욱 편하다.
객체의 구조가 복잡해지거나 객체로 이루어진 배열을 다룰 경우, immer를 사용하면 훨씬 편리하게 상태를 관리할 수 있다.
우선 immer를 현재 프로젝트에 설치해 준다.
$ yarn add immer
counter 모듈처럼 간단한 리듀서에 immer를 사용하면 오히려 코드가 더 길어지기 때문에 todos 모듈에 적용해 보겠다.
import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';
(...)
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);
}),
[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;
immer를 사용한다고 해서 모든 업데이트 함수에 immer를 적용할 필요는 없다. 일반 자바스크립트로 처리하는 것이 더 편할 때는 immer를 적용하지 않아도 된다.
예를 들어 위 코드에서 TOGGLE을 제외한 업데이트 함수들은 immer를 쓰지 않는 코드가 오히려 더 짧기 때문에 이전 형태를 유지해도 무방하다.
리덕스 스토어와 연동된 컨테이너 컴포넌트를 만들 때 connect 함수를 사용하는 대신 react-redux에서 제공하는 Hooks를 사용할 수도 있다.
useSelector Hook을 사용하면 connect 함수를 사용하지 않고도 리덕스의 상태를 조회할 수 있다. useSelector의 사용법은 다음과 같다.
const 결과 = useSelector(상태 선택 함수);
여기서 상태 선택 함수는 mapStateToProps와 형태가 똑같다. 이제 CounterContainer에서 connect 함수 대신 useSelector를 사용하여 counter.number 값을 조회함으로써 Counter에게 props를 넘겨 준다.
import React from 'react';
import { useSelector } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';
const CounterContainer = () => {
const number = useSelector(state => state.counter.number);
return <Counter number={number} />;
};
export default CounterContainer;
이번에는 useDispatch라는 Hook에 대해 알아보자. 이 Hook은 컴포넌트 내부에서 스토어의 내장 함수 dispatch를 사용할 수 있게 해 준다. 컨테이너 컴포넌트에서 액션을 디스패치해야 한다면 이 Hook을 사용하면 된다. 사용법은 다음과 같다.
const dispatch = useDispatch();
dispatch({ type: 'SAMPLE_ACTION' });
이제 CounterContainer에서도 이 Hook을 사용하여 INCREASE와 DECREASE 액션을 발생시켜 보자.
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';
const CounterContainer = () => {
const number = useSelector(state => state.counter.number);
const dispatch = useDispatch();
return (
<Counter
number={number}
onIncrease={() => dispatch(increase())}
onDecrease={() => dispatch(decrease())}
/>
);
};
export default CounterContainer;
이렇게 코드를 작성하고 +1과 -1 버튼을 눌러서 숫자가 잘 바뀌는지 확인한다.
지금은 숫자가 바뀌어서 컴포넌트가 리렌더링될 때마다 onIncrease 함수와 onDecrease 함수가 새롭게 만들어지고 있다.
만약 컴포넌트 성능을 최적화해야 하는 상황이라면 useCallback으로 액션을 디스패치하는 함수를 감싸 주는 것이 좋다.
다음과 같이 코드를 한번 수정해 보자.
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';
const CounterContainer = () => {
const number = useSelector(state => state.counter.number);
const dispatch = useDispatch();
const onIncrease = useCallback(() => dispatch(increase()), [dispatch]);
const onDecrease = useCallback(() => dispatch(decrease()), [dispatch]);
return (
<Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} />
);
};
export default CounterContainer;
useDispatch를 사용할 때는 이렇게 useCallback과 함께 사용하는 습관을 들일 것을 권한다.
useStore Hooks를 사용하면 컴포넌트 내부에서 리덕스 스토어 객체를 직접 사용할 수 있습니다. 사용법은 다음과 같다.
const store = useStore();
store.dispatch({ type: 'SAMPLE_ACTION '});
store.getState();
useStore는 컴포넌트에서 정말 어쩌다가 스토어에 직접 접근해야 하는 상황에만 사용해야 한다. 이를 사용해야 하는 상황은 흔치 않을 것다.
이제 TodosContainer를 connect 함수 대신에 useSelector와 useDispatch Hooks를 사용하는 형태로 전환해 보자.
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import Todos from '../components/Todos';
const TodosContainer = () => {
const { input, todos } = useSelector(({ todos }) => ({
input: todos.input,
todos: todos.todos
}));
const dispatch = useDispatch();
const onChangeInput = useCallback(input => dispatch(changeInput(input)), [
dispatch
]);
const onInsert = useCallback(text => dispatch(insert(text)), [dispatch]);
const onToggle = useCallback(id => dispatch(toggle(id)), [dispatch]);
const onRemove = useCallback(id => dispatch(remove(id)), [dispatch]);
return (
<Todos
input={input}
todos={todos}
onChangeInput={onChangeInput}
onInsert={onInsert}
onToggle={onToggle}
onRemove={onRemove}
/>
);
};
export default TodosContainer;
이번에는 useSelector를 사용할 때 비구조화 할당 문법을 활용했다.
또한, useDispatch를 사용할 때 각 액션을 디스패치하는 함수를 만들었다. 위 코드의 경우 액션의 종류가 많은데 어떤 값이 액션 생성 함수의 파라미터로 사용되어야 하는지 일일이 명시해 주어야 하므로 조금 번거롭다.
이 부분은 우선 컴포넌트가 잘 작동하는 것을 확인하고 나서 한번 개선해 보겠다. 코드를 저장하고 TodosContainer가 잘 작동하는지 확인한다.
useActions는 원래 react-redux에 내장된 상태로 릴리즈될 계획이었으나 리덕스 개발 팀에서 꼭 필요하지 않다고 판단하여 제외된 Hook이다. 그 대신 공식 문서에서 그대로 복사하여 사용할 수 있도록 제공하고 있다.
이 Hook을 사용하면, 여러 개의 액션을 사용해야 하는 경우 코드를 훨씬 깔끔하게 정리하여 작성할 수 있다.
src 디렉터리에 lib 디렉터리를 만들고, 그 안에 useActions.js 파일을 다음과 같이 작성해 보자.
import { bindActionCreators } from 'redux';
import { useDispatch } from 'react-redux';
import { useMemo } from 'react';
export default 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] : deps
);
}
방금 작성한 useActions Hook은 액션 생성 함수를 액션을 디스패치하는 함수로 변환해 준다. 액션 생성 함수를 사용하여 액션 객체를 만들고, 이를 스토어에 디스패치하는 작업을 해 주는 함수를 자동으로 만들어 주는 것이다.
useActions는 두 가지 파라미터가 필요하다.
첫 번째 파라미터는 액션 생성 함수로 이루어진 배열이다.
두 번째 파라미터는 deps 배열이며, 이 배열 안에 들어 있는 원소가 바뀌면 액션을 디스패치하는 함수를 새로 만들게 된다.
한번 TodoContainer에서 useActions를 불러와 사용해 보자.
import React from 'react';
import { useSelector } 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;
코드를 저장한 뒤, TodoListContainer가 잘 작동하는지 다시 확인한다.
앞으로 컨테이너 컴포넌트를 만들 때 connect 함수를 사용해도 좋고, useSelector와 useDispatch를 사용해도 좋다. 리덕스 관련 Hook이 있다고 해서 기존 connect 함수가 사라지는 것은 아니므로, 더 편한 것을 사용하면 된다.
하지만 Hooks를 사용하여 컨테이너 컴포넌트를 만들 때 잘 알아 두어야 할 차이점이 있다. connect 함수를 사용하여 컨테이너 컴포넌트를 만들었을 경우, 해당 컨테이너 컴포넌트의 부모 컴포넌트가 리렌더링될 때 해당 컨테이너 컴포넌트의 props가 바뀌지 않았다면 리렌더링이 자동으로 방지되어 성능이 최적화된다.
반면 useSelector를 사용하여 리덕스 상태를 조회했을 때는 이 최적화 작업이 자동으로 이루어지지 않으므로, 성능 최적화를 위해서는 다음과 같이 React.memo를 컨테이너 컴포넌트에 사용해 주어야 한다.
import React from 'react';
import { useSelector } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import Todos from '../components/Todos';
import useActions from '../lib/useActions';
const TodosContainer = () => {
(...)
};
export default React.memo(TodosContainer);
물론 지금과 같은 경우에는 TodosContainer의 부모 컴포넌트인 App 컴포넌트가 리렌더링되는 일이 없으므로 불필요한 성능 최적화이다.
이 장에서는 리덕스 미들웨어를 사용하여 비동기 작업을 처리하는 방법을 살펴보았다. redux-thunk는 간단하고 명료한 일반 함수로 구성되어 있어 사용이 쉽지만, redux-saga는 진입 장벽이 높을 수 있지만 더 효율적으로 복잡한 상황에서 작업을 관리할 수 있다.
미들웨어 외에도 redux-promise-middleware, redux-pender, redux-observable 등을 시도할 수 있다. 미들웨어를 사용하지 않고 컴포넌트에서 직접 API를 호출하는 것도 올바른 방법이며, 미들웨어를 사용하는 이유는 주로 편의성 때문이므로 사용이 불편하게 느껴진다면 사용하지 않는 것도 고려할 수 있다.
1. (리덕스)는 리액트 상태 관리 라이브러리이며, 컴포넌트의 상태 업데이트 관련 로직을 다른 파일로 분리시켜서 효율적으로 관리 가능하다.
2. 스토어 내장 함수의 종류는 (디스패치), (구독) 이다.
3. 리듀서 함수가 맨 처음 호출될 때는 state 값이 (undefined)이다.
4. (dispatch)는 액션을 발생시키는 것으로, 이벤트 함수 내부에서 이 함수를 사용하여 액션을 스토어에게 전달할 수 있다.
5. 리덕스를 사용할 때 액션 타입, 액션 생성 함수, 리듀서 함수를 기능별로 파일 하나에 몰아서 다 작성하는 방식을
( Ducks 패턴 ) 라고 한다.
6. 액션 타입을 정의할 때, 액션 타입은 ( 대문자 )로 정의하고, 문자열 내용은 ( '모듈 이름/액션 이름' )과 같은 형태로 작성한다.
1. 리듀서 함수는 변화에 따라 동작하게 만들어진 함수이다. 액션 객체의 type 값이 INCREMENT일때 counter을 1개 증가시키고, 그 이외의 값은 state를 그대로 출력한다. 빈칸을 알맞게 채우시오.
const initialState = {
counter: 1
};
funtion reducer(state = _________, _____) {
switch(____________) {
case INCREMENT :
return {
counter: state.counter + 1
};
default:
retrun state;
}
}
2. Redux 액션을 생성하기 위해 createAction 함수를 사용해보고자 한다. { type: MY_ACTION, payload: 'hello world' }의 결과가 나오도록 코드를 작성하시오.
const MY_ACTION = 'sample/MY_ACTION';
/*
이곳에 코드 작성
*/
정답
1.
const initialState = {
counter: 1
};
funtion reducer(state = initialState, action) { //리듀서가 현재 상태와 액션 객체를 파라미터로 받아옴.
switch(action.type) {//액션 객체의 type에 따라
case INCREMENT : //증가라면
return {
counter: state.counter + 1 //counter의 값을 증가
};
default:
retrun state;
}
}
2.
const MY_ACTION = 'sample/MY_ACTION';
const myAction = createAction(MY_ACTION);
const action = myAction('hello world');
[리액터 스타터3] project 3 [감정 일기장] 만들기 (0) | 2024.01.12 |
---|---|
[리액터 스타터3] 9장 컴포넌트 트리에 데이터 공급하기 (1) | 2024.01.05 |
[리액터 스타터3] 7장 useReducer와 상태 관리 / 8장 최적화 (0) | 2023.12.29 |
[리액터 스타터3] project 2. [할 일 관리] 앱 만들기 2 (1) | 2023.12.22 |
[리액터 스타터3] project 2. [할 일 관리] 앱 만들기 1 (1) | 2023.12.01 |