이 포스팅은 저자 김민준 님의 ‘리액트를 다루는 기술’을 참고하여 작성되었습니다.
React 프로젝트에서 Redux를 이용한 상태관리
- 리액트에서 리덕스를 사용할 때 많이 사용하는 패턴은 프레젠테이셔널 컴포넌트와 컨테이너 컨포넌트를 분리하는 것이다.
- 프레젠테이셔널 컴포넌트 : 상태관리가 이루어지지 않고, 그저 props를 받아와서 화면에 UI를 보여주기만 하는 컴포넌트
- 컨테이너 컴포넌트 : 리덕스와 연동된 컴포넌트로, 리덕스로부터 상태를 받아오기도 하고 리덕스 스토어에 액션을 디스패치 하기도 한다.
카운터 컴포넌트 만들기
숫자를 더하고 뺄 수 있는 카운터 컴포넌트 생성
components/Counter.js
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.js에 렌더링
import React from 'react';
import Counter from './components/Counter';
const App = () => {
return (
<div>
<Counter number={0} />
</div>
);
};
export default App;
할 일 목록 컴포넌트 만들기
- 해야 할 일을 추가하고, 체크하고, 삭제할 수 있는 할일 목록 컴포넌트 생성
components/Todos.js
import React from 'react';
const TodoItem = ({ todo, onToggle, onRemove }) => {
return (
<div>
<input type="checkbox" />
<span>예제 텍스트</span>
<button>삭제</button>
</div>
);
};
const Todos = ({
input, // 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 컴포넌트에서 카운터 아래에 렌더링
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 모듈 작성
modules/counter.js 생성
// 액션 타입 정의
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;
// export : 여러개를 내보낼 수 있고
// export default : 단 한 개만 내보낼 수 있다.
- 액션 타입 정의 : 액션 타입은 대문자로 정의하고, 문자열 내용을 정의할때는
‘모듈 이름/액션 이름’
과 같은 형태로 작성한다. - 액션 생성 함수 만들기 : 앞부분에 export 키워드가 들어가며, 이 함수를 다른 파일에서 불러와 사용 가능하다.
- 초기 상태 및 리듀서 함수 만들기 : 초기상태에는 number 값을 설정해 줌, 리듀서 함수에는 현재 상대를 참조하여 객체를 반환하는 코드를 작성
todos 모듈 만들기
modules/todos.js 생성
// 액션 타입 정의
import Todos from "../components/Todos";
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씩 더해짐, todo객체가 사전에 2개 미리 들어갈것이라서 초기값 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{
...state,
input: action.input
};
case INSERT:
return{
...state,
input: 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;
- 액션 타입 정의 :
‘모듈 이름/액션 이름’
과 같은 형태로 작성 - 액션 생성 함수 만들기 : counter와 달리, 액션 생성함수에 파라미터가 들어간다.
- 초기상태 및 리듀서 함수 생성 : 객체에 한개 이상의 값이 들어가므로 불변성을 유지해 주어야 한다. → spread 연산자를 활용해서 작성
루트 리듀서 만들기
리듀서를 여러개 만들었는데, store를 만들때는 리듀서를 하나만 사용할수 있기 때문에 리듀서를 하나로 합쳐주는 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에서 이루어진다.
스토어 만들기
리액트 컴포넌트에서 스토어 사용을 위해 App을 Provider 컴포넌트로 감싸준다.
src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {createStore} from "redux";
import rootReducer from "./modules";
import {Provider} from "react-redux";
const store = createStore(rootReducer);
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<App/>
</Provider>
);
reportWebVitals();
Redux DevTools의 설치 및 적용
구글 크롬 Redux DevTools 설치 및
yarn add redux-devtools-extension
패키지 설치
src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {createStore} from "redux";
import rootReducer from "./modules";
import {Provider} from "react-redux";
import {composeWithDevTools} from "redux-devtools-extension";
const store = createStore(rootReducer, composeWithDevTools());
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<App/>
</Provider>
);
reportWebVitals();
개발자 도구의 Redux 탭에서 Action 및 State의 상태를 확인 할 수 있다.
컨테이너 컴포넌트 만들기
리덕스 스토어에 접근하여 원하는 상태를 받아오고, 액션도 디스패치 하기위해서 스토어와 연동된 컨테이너 컴포넌트를 만들어 보자.
CounterContainer 만들기
src/containers/CounterContainer.js
import React from 'react';
import Counter from "../modules/counter";
const CounterContainer = () => {
return (
<Counter/>
);
};
export default CounterContainer;
위의 컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect를 사용해야 한다.
connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)
mapStateToProps
: 리덕스 스토어 안에 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수mapDispatchToProps
: 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수
CounterContainer 컴포넌트에서 connect를 사용
src/containers/CounterContainer.js
import React from 'react';
import Counter 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: () => {
console.log('increase')
},
decrease: () => {
console.log('decrease')
},
});
export default connect(
mapStateToProps,
mapDispatchToProps,
)(CounterContainer);
mapStateToProps
와 mapDispatchProps
에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달된다.
mapStateToProps
:state
를 파라미터로 받아오며, 이 값은 현재 스토어가 지니고 있는 상태를 가리킨다.mapDispatchProps
: store의 내장함수dispatch
를 파라미터로 받아온다.
App에서 Counter 컴포넌트를 CounterContainer로 교체
App.js
const App = () => {
return (
<div>
<CounterContainer/>
<hr/>
<Todos />
</div>
);
}
export default App;
버튼을 누르면 콘솔에 decrease와 increase가 찍힌다.
console.log 대신 액션 객체를 불러와서 dispatch
src/containers/CounterContainer.js
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import {decrease, increase} 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);
일반적으로 위와 같이 mapStateToProps
, mapDispatchToProps
를 미리 선언해놓고 사용하지만 connet 내에 익명함수 형태로 사용해도 된다.
containerws/CounterContainers.js
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import {decrease, increase} 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);
bindActionCreators를 사용해서 디스패치 하는 방법
- 컴포넌트에서 액션을 디스패치 편하게 할 수 있도록 제공해주는 유틸 함수
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import {decrease, increase} from "../modules/counter";
import {bindActionCreators} from "redux";
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 {decrease, increase} from "../modules/counter";
import {bindActionCreators} from "redux";
const CounterContainer = ({number, increase, decrease}) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease}/>
);
};
export default connect(
state => ({
number: state.counter.number,
}),
{
increase,
decrease,
},
)
(CounterContainer);
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);
App 컴포넌트에서 Todos컴포넌트를 TodosContainer 컴포넌트로 교체
import './App.css';
import Todos from "./components/Todos";
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, // 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;