지난번에 리덕스에 대해 소개를 했으니 이번에는 리덕스를 간단하게 사용해볼 차례입니다. 일반 리액트앱에 리덕스를 이용하면 상태 관리 로직을 따로 만들고 관리가 가능해져, 프로젝트의 유지보수가 쉬워지고 다양한 편의 기능을 제공합니다.
리덕스, 리액트 리덕스를 설치해주세요.
yarn add redux
yarn add react-redux
1. 카운터/Todo 프로그램
우선 다음과 같은 카운터/Todo 기능이 있는 리액트 앱을 만들어줍니다.
import React from 'react';
const ReduxCounter = ({number, onIncrease, onDecrease}) => {
return (
<div>
<h1>{number}</h1>
<div>
<button onClick={onIncrease}>+1</button>
<button onClick={onDecrease}>-1</button>
</div>
</div>
);
};
export default ReduxCounter;
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/>
</div>
</div>
);
};
export default Todos;
import React from 'react';
import ReduxCounter from './codes/redux-tutorial/ReduxCounter';
import Todos from './codes/redux-tutorial/Todos';
const App = () => {
return (
<div>
<ReduxCounter number={0}/>
<hr/>
<Todos/>
</div>
);
};
export default App;
2. 리덕스 코드 작성
2-1. 파일 구조
리덕스 코드를 정의할 때 action, action creator, reducer를 작성해야합니다. 그래서 이들을 작성할 때 actions, constants, reducers라는 디렉토리를 만들고 기능별로 작성하는 것이 공식적이고 기본적인 파일 구조입니다. 분류가 잘되어서 찾기 편하고 유지보수가 용이하다는 장점을 갖지만 action이 추가되는 경우 세 종류의 파일들을 모두 수정해야 하므로 번거로운 단점을 가지고 있습니다.
이런 단점을 해결하기 위한 방식이 Ducks 방식입니다. 위의 구조와 다르게 action, action creator, reducer를 하나의 디렉토리에서 작성하는 방식입니다. Ducks 방식을 이용해서 작성한 코드들은 '모듈'이라고 부르게 됩니다.
2-2. 카운터 프로그램의 모듈
우선 카운터 프로그램의 모듈부터 생성해보겠습니다. 가장 먼저 해야 할 일은 액션 타입의 정의입니다. 만든 모듈 파일 내부에 카운터 파일을 생성하고 다음과 같이 액션 타입을 정의합니다.
액션 타입은 대문자로 정의하고 문자열의 내용은 '모듈명/액션명'으로 표기합니다. 모듈명을 액션명 앞에 붙임으로써 중복 정의로 인한 충돌을 방지하게 됩니다.
const INCREASE = 'reduxCounter/INCREASE';
const DECREASE = 'reduxCounter/DECREASE';
이어서 액션 생성 함수를 만들 차례입니다. increase 가 발생하면 INCREASE type 액션을 불러오고, decrease가 발생하게 되면 DECREASE type 액션을 생성하게 됩니다. 이때 액션 생성 함수는 외부에서 불러와 사용하기 위에 앞에 export 키워드를 붙입니다.
const INCREASE = 'reduxCounter/INCREASE';
const DECREASE = 'reduxCounter/DECREASE';
export const increase = () => ({
type: INCREASE,
});
export const decrease = () => ({
type: DECREASE,
});
마지막으로 카운터 모듈의 초기 상태와 리듀서 함수를 생성해보겠습니다.
초기 상태는 카운터가 0부터 시작할 것이므로 0으로 주었습니다.
그리고 리듀서 함수는 default function parameter 문법으로 초기 상태를 기본 값으로 준 상태와 액션을 파라미터로 이용합니다.
const INCREASE = 'reduxCounter/INCREASE';
const DECREASE = 'reduxCounter/DECREASE';
export const increase = () => ({
type: INCREASE,
});
export const decrease = () => ({
type: DECREASE,
});
const initialState = {
number: 0
};
function reduxCounter(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 reduxCounter;
모듈을 내보낼 때 action creator는 export로, reducer는 export default로 내보냈습니다. 두 방식의 차이점은 export는 여러 개를 내보내고, export default는 단 한 개만을 내보낼 수 있습니다. 또 모듈을 불러올 때 export는 {}중괄호로 묶어서 불러오고, export default는 중괄호 없이 불러옵니다.
2-3. Todos 프로그램 모듈
이번엔 Todos의 모듈을 만들어볼 차례입니다. 마찬가지로 액션 타입부터 정의해보겠습니다. (끝나고 검수할 때 보니 파일명이 통일되지 않았습니다. 죄송합니다.)
const CHANGE_INPUT = 'todos/CHANGE_INPUT';
const INSERT = 'todos/INSERT';
const TOGGLE = 'todos/TOGGLE';
const REMOVE = 'todos/REMOVE';
다음은 액션 타입에 따른 액션 생성 함수입니다.
const CHANGE_INPUT = 'todos/CHANGE_INPUT';
const INSERT = 'todos/INSERT';
const TOGGLE = 'todos/TOGGLE';
const REMOVE = 'todos/REMOVE';
export const changeInput = input => ({
type: CHANGE_INPUT,
input,
});
//todo의 id
//다음 절에서 초기 상태에 2개를 넣을 예정이라 3으로 설정
let id = 3;
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 {
//전개 연산자를 통한 deep copy로 불변성 유지
...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;
2-4. Root Reducer
reduxCounter와 todos, 두 개의 리듀서를 만들었습니다. 하지만 추후에 스토어를 만들기 위해서는 리듀서를 하나만 사용해야합니다. 그래서 이 두 개의 리듀서를 합치는 작업을 해야합니다. modules 디렉토리 내부에 index.js 파일을 만들어주세요.
여러개의 리듀서를 하나로 합쳐주는 일은 리덕스에서 제공하는 combineReducer를 이용합니다. 다음과 같이 작성하면 추후에 rootReducer 모듈 하나만 불러와도 여러개의 리듀서를 이용할 수 있게됩니다.
import {combineReducers} from 'redux';
import reduxCounter from './reduxCounter';
import todos from './todos';
const rootReducer = combineReducers({reduxCounter, todos});
export default rootReducer;
3. 리액트 앱에 리덕스 적용하기
리액트 코드도 완성해뒀고, 리덕스 코드도 방금 작성을 완료했으니 이제는 리액트 앱에 리덕스 코드를 적용시킬 차례입니다. 리덕스 적용은 modules의 index.js가 아니라 src의 index.js에서 실행됩니다.
store를 생성하고 react-redux의 Provider 컴포넌트를 이용합니다. Provider 컴포넌트의 props로 생성한 store를 전달하면 됩니다.
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 rootReducer from './codes/redux-tutorial/ducks-modules';
const store = createStore(rootReducer);
ReactDom.render(
<Provider store={store}>
<App/>,
</Provider>,
document.getElementById('root'),
)
;
3-1. 리덕스 개발자 도구
이번에 소개할 것은 크롬의 확장 프로그램인 Redux DevTools입니다. 이 도구를 통해 상태나 추적 등을 할 수 있습니다.
이 도구를 사용하기 위해서 스토어에 코드를 달아야 하는데요. 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 rootReducer from './codes/redux-tutorial/ducks-modules';
const store = createStore(rootReducer, composeWithDevTools());
ReactDom.render(
<Provider store={store}>
<App/>,
</Provider>,
document.getElementById('root'),
);
작성 후 개발자 도구를 열고 Redux 탭을 누릅니다. DevTool이 잘 표시가 된다면 성공한 것 입니다.
4. 컨테이너 컴포넌트
이제 적용까지 했으니 컴포넌트에서 조작을 하면 스토어에 접근해 상태를 받아오거나 디스패치하는 행동을 구현할 차례입니다. 이렇게 컴포넌트와 스토어를 연결한 컴포넌트를 컨테이너 컴포넌트라고 합니다.
4-1. 카운터 컨테이너 컴포넌트
다음 코드는 카운터 컴포넌트의 컨테이너 컴포넌트 입니다.
import React from 'react';
import ReduxCounter from '../ReduxCounter';
const ReduxCounterContainer = () => {
return <ReduxCounter />;
};
export default ReduxCounterContainer;
만든 컨테이너 컴포넌트를 리덕스와 연결하기 위해서 react-redux 패키지의 connect 함수를 이용해야합니다.
connect(mapStateToProps, mapDispatchToProps)(타겟 컴포넌트);
mapStateToProps는 스토어의 State를 컴포넌트의 props로 넘기기 위한 함수입니다. mapDispatchToProps는 action creator를 컴포넌트의 props로 넘기기 위한 함수입니다. 타겟 컴포넌트는 연결할 컴포넌트를 넣으면 됩니다.
그러면 위에서 만든 컨테이너 컴포넌트를 connect로 연결해보겠습니다.
import React from 'react';
import {connect} from 'react-redux';
import ReduxCounter from '../ReduxCounter';
const ReduxCounterContainer = ({number, increase, decrease}) => {
return <ReduxCounter number={number} onIncrease={increase} onDecrease={decrease}/>;
};
const mapStateToProps = state => ({
number: state.reduxCounter.number,
});
const mapDispatchToProps = dispatch => ({
increase: () => console.log('증가'),
decrease: () => console.log('감소'),
});
export default connect(mapStateToProps, mapDispatchToProps)(ReduxCounterContainer);
mapStateToProps의 state 파라미터는 현재 스토어의 state를 가리킵니다. 그리고 mapDispatchToProps의 파라미터인 dispatch는 store의 내장함수 dispatch를 파라미터로 받습니다. 이 두 함수가 반환하는 객체 값은 컴포넌트의 props로 전달되게 됩니다.
그리고 App.js의 카운터 컴포넌트를 지금 만든 카운터 컨테이너 컴포넌트로 변경해줍니다.
import React from 'react';
import Todos from './codes/redux-tutorial/Todos';
import ReduxCounterContainer from './codes/redux-tutorial/containers/ReduxCounterContainer';
const App = () => {
return (
<div>
<ReduxCounterContainer/>
<hr/>
<Todos/>
</div>
);
};
export default App;
이제 동작을 확인했으니 increase와 decrease 버튼을 누르면 카운터 숫자가 올라가도록 변경해보겠습니다. 액션을 발생시키는 store의 내장함수인 dispatch 부분만 변경하면 됩니다.
import React from 'react';
import {connect} from 'react-redux';
import ReduxCounter from '../ReduxCounter';
import {decrease, increase} from '../ducks-modules/reduxCounter';
const ReduxCounterContainer = ({number, increase, decrease}) => {
return <ReduxCounter number={number} onIncrease={increase} onDecrease={decrease}/>;
};
const mapStateToProps = state => ({
number: state.reduxCounter.number,
});
//증가 감소가 dispatch 되도록
const mapDispatchToProps = dispatch => ({
increase: () => dispatch(increase()),
decrease: () => dispatch(decrease()),
});
export default connect(mapStateToProps, mapDispatchToProps)(ReduxCounterContainer);
버튼을 누르면 카운터의 숫자가 변경됩니다. 그리고 reduxDevTool을 보면 상태 변화 기록을 볼 수 있습니다.
4-2. 리덕스 제공 기능으로 코드를 깔끔하게 만들기
리덕스는 전역 상태 관리 뿐 만 아니라 다양한 편의 기능도 제공한다고 알려드렸습니다. 그래서 완성된 컨테이너 컴포넌트를 편의 기능을 이용해서 간결한 코드로 변경해보겠습니다.
connect의 mapStateToProps와 mapDispatchToProps를 함수로 따로 선언하지 않고 내부에 익명 함수 형태로 선언하는 것 입니다. 이것은 리덕스 제공 방식 보다는 프로그래밍 스킬에 가까운 방식이긴 합니다. 이 방식을 이용하면 코드가 더 간결해집니다. (함수의 내용에 따라서 오히려 가독성을 해칠수가 있습니다.)
//변경 전 코드
import React from 'react';
import {connect} from 'react-redux';
import ReduxCounter from '../ReduxCounter';
import {decrease, increase} from '../ducks-modules/reduxCounter';
const ReduxCounterContainer = ({number, increase, decrease}) => {
return <ReduxCounter number={number} onIncrease={increase} onDecrease={decrease}/>;
};
const mapStateToProps = state => ({
number: state.reduxCounter.number,
});
const mapDispatchToProps = dispatch => ({
increase: () => dispatch(increase()),
decrease: () => dispatch(decrease()),
});
export default connect(mapStateToProps, mapDispatchToProps)(ReduxCounterContainer);
//변경 후 코드
import React from 'react';
import {connect} from 'react-redux';
import ReduxCounter from '../ReduxCounter';
import {decrease, increase} from '../ducks-modules/reduxCounter';
const ReduxCounterContainer = ({number, increase, decrease}) => {
return <ReduxCounter number={number} onIncrease={increase} onDecrease={decrease}/>;
};
export default connect(
state => ({number: state.reduxCounter.number}),
dispatch => ({
increase: () => dispatch(increase()),
decrease: () => dispatch(decrease()),
})
)(ReduxCounterContainer);
mapDispatchToProps에서 액션 함수들을 호출할 때 dispatch()로 감싸는 작업도 간단하게 줄일 수 있습니다. 리덕스에서 제공하는 bindActionCreators 함수를 이용하면 다음과 같이 간단하게 줄일 수 있습니다.
//bindActionCreators 사용 전
import React from 'react';
import {connect} from 'react-redux';
import ReduxCounter from '../ReduxCounter';
import {decrease, increase} from '../ducks-modules/reduxCounter';
const ReduxCounterContainer = ({number, increase, decrease}) => {
return <ReduxCounter number={number} onIncrease={increase} onDecrease={decrease}/>;
};
export default connect(
state => ({number: state.reduxCounter.number}),
dispatch => ({
increase: () => dispatch(increase()),
decrease: () => dispatch(decrease()),
})
)(ReduxCounterContainer);
//bindActionCreators 사용 후
import React from 'react';
import {connect} from 'react-redux';
import ReduxCounter from '../ReduxCounter';
import {decrease, increase} from '../ducks-modules/reduxCounter';
import {bindActionCreators} from 'redux';
const ReduxCounterContainer = ({number, increase, decrease}) => {
return <ReduxCounter number={number} onIncrease={increase} onDecrease={decrease}/>;
};
export default connect(
state => ({number: state.reduxCounter.number}),
dispatch => bindActionCreators({
increase,
decrease,
},
dispatch,
),
)(ReduxCounterContainer);
dispatch => bindActionCreators({
increase,
decrease,
},
dispatch,
),
dispatch 부분이 확실히 간단해진 것이 보이나요? 가독성 면에서도 저는 더 좋아졌다고 개인적으로 느꼈습니다.
여기서 한 번 더 간결한 구문을 작성할 수 있습니다. dispatch 파라미터 함수 대신 action creator로 구성된 객체를 넣어주면 더 간단한 표현이 가능합니다. 다음과 같이 액션 생성 함수로 이루어진 객체를 파라미터로 전달하면 내부적으로 위의 bindActionCreators와 같은 동작을 하게 됩니다.
export default connect(
state => ({number: state.reduxCounter.number}),
{
increase,
decrease,
},
)(ReduxCounterContainer);
4-3. todos 컴포넌트 컨테이너
이제 todos 컴포넌트의 컨테이너를 만들어보겠습니다. 먼저 TodosContainer.js 컴포넌트입니다.
import React from 'react';
import {connect} from 'react-redux';
import {changeInput, insert, toggle, remove} from '../ducks-modules/todos';
import Todos from '../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: todos.todos,
}),
{
changeInput,
insert,
toggle,
remove,
},
)(TodosContainer);
그리고 App.js의 Todos 컴포넌트를 컨테이너 컴포넌트로 변경합니다.
import React from 'react';
import ReduxCounterContainer from './codes/redux-tutorial/containers/ReduxCounterContainer';
import TodosContainer from './codes/redux-tutorial/containers/TodosContainer';
const App = () => {
return (
<div>
<ReduxCounterContainer/>
<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;
이렇게 모든 상태 변화에 대해 리덕스 개발 도구에서 볼 수 있고 제대로 작동하는 것을 볼 수 있습니다. 그리고 이것으로 리덕스를 이용한 앱의 상태 변화 예제를 마치겠습니다.
중간에 끊기가 애매한 내용이라 한 큐에 가다보니 포스트 많이 길어졌습니다. 처음 접하면 다소 복잡하다고 생각될 수 있으므로 리덕스 기초 포스팅과 함께 읽어나가시길 추천드립니다.
참조
'Programming > React' 카테고리의 다른 글
[React/Redux] 리덕스 미들웨어 (0) | 2022.01.18 |
---|---|
[React/Redux] 리덕스 활용 (0) | 2022.01.06 |
[Redux] 리덕스 (0) | 2022.01.04 |
[React] useState의 비동기적 동작 (0) | 2021.12.27 |
react-icons (0) | 2021.12.16 |
댓글