이번 포스팅에서는 상태관리 라이브러리인 Redux를 React 환경해서 사용하는 방법을 정리겠습니다.
리액트에서 웹 개발을 할 때 계속해서 값이 변경되는 데이터를 state로 지정하고 특정 로직에 따라 변경하여 화면에 보여줍니다. 특정 컴포넌트 내부에서 관리되는 로컬에서의 상태 관리와 프로덕트 전체나 여러가지 컴포넌트에서 관리되는 전역에서의 상태 관리로 구분할 수 있습니다.
상태 값이 적을 때는 로컬에서 상태를 관리해도 충분합니다. 하지만 프로젝트 규모가 크고 수 많은 컴포넌트 간에 상태값을 공유하거나 변경해야 한다면 작성해 주어야하는 코드가 기하급수 적으로 늘어나게 될 것 입니다.
그리고 Props를 통해 상태값을 전달하게 된다면 코드의 가독성이 떨어지고 유지보수가 힘들어지게 됩니다. 또한 state 변경시 Props 전달 과정에서 불필요하게 관여된 컴포넌트들 또한 리렌더링이 발생합니다.
하지만 하나의 공간에 모아 두고 데이터를 전역 상태로 관리하게 된다면 좀 더 예측 가능한 프로덕트를 만들어 낼 수 있습니다. Redux는 그런 상태관리 라이브러리 중에 하나입니다.
npm 명령어를 통해서 라이브러리를 설치해줍니다. 리액트에서 redux 라이브러리를 더 효율적으로 사용할 수 있는 react-redux 라이브러리도 설치하겠습니다.
npm install redux
npm install react-redux
리덕스에서는 Store라는 상태 저장소를 사용합니다. 이 Store에서 관리되는 상태 값은 일반적인 방법으로 꺼내오거나 변경하는 것이 불가능하며, 정해진 방식을 통해서만 가져오거나 변경할 수 있습니다. 이러한 방식을 사용하는 이유는 Store 내부의 상태값의 안정성을 유지하기 위함입니다.
리덕스에서는 Action → Dispatch → Reducer → Store 순서로 데이터가 단방향으로 흐르게 됩니다. 각 단계를 코드로 구현해보도록 하겠습니다.
redux 라이브러리에서 createStore 메소드를 통해서 Store 객체를 생생해 줍니다. 그리고 react-redux 라이브러리에서 Provider 컴포넌트를 가져와 우리가 스토어를 통해 상태를 관리해줄 컴포넌트를 감싸주면 됩니다. 전역적으로 상태를 관리하기 위해서 App 컴포넌트를 감싸줍니다.
여기까지 한다면 상태 저장소를 만들고 전역 상태에서 관리하기 위한 준비를 마친 것 입니다.
// index.js
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { Provider } from "react-redux";
import { createStore } from "redux";
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
const store = createStore();
root.render(
<Provider store={store}>
<App />
</Provider>
);
이번에는 상태를 변경시키는 reducer에 대해 대해서 알아보겠습니다. reducer 함수는 첫 번째 매개변수로 현재 state값을 받고 두번째 매개변수로 action이라는 객체를 받습니다. 그리고 전달받은 action에 따라 state 값을 변경시킵니다.
아래의 코드를 보면 action 객체의 type에 따라서 state 값을 증가하가나 감소시키고 정해진 action 객체가 아니면 기본값으로 기존의 state를 그대로 리턴해 줍니다. reducer 함수의 내부 로직에 따라 다양한 방식으로 state 값을 변경 시킬 수 있습니다.
reducer 함수를 생성한 후에 createStore에 전달해주면 전달받은 reducer 함수를 통해 상태를 변경할 준비를 마친 것 입니다.
// index.js
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { Provider } from "react-redux";
import { createStore } from "redux";
const count = 1;
// reducer 생성
const reducer = (state = count, action) => {
switch (action.type) {
//action === 'INCREASE'일 경우
case "INCREASE":
return state + 1;
// action === 'DECREASE'일 경우
case "DECREASE":
return state - 1;
// action === 'SET_NUMBER'일 경우
case "SET_NUMBER":
return action.payload;
// 해당 되는 경우가 없을 땐 기존 상태를 그대로 리턴
default:
return state;
}
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
const store = createStore(reducer);
root.render(
<Provider store={store}>
<App />
</Provider>
};
이번에는 reducer 함수로 전달되는 action 객체에 대해 알아보겠습니다. action 객체는 필수적으로 type이라는 키를 가지고 그 외에 필요한 데이터를 payload로 가집니다.
// [예시]
// payload가 필요 없는 경우
const action = {
type : "INCREASE"
}
// payload가 필요한 경우
const action = {
type : "INCREASE",
payload : 10;
}
액션 객체는 직접 작성해도 함수를 사용하여 리턴해주는 방법도 있습니다. 이번에는 action 객체를 리턴하는 함수를 만들어서 다른 컴포넌트에서 사용할 수 있도록 export 해주도록 하겠습니다.
// index.js
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { Provider } from "react-redux";
import { createStore } from "redux";
//action 객체를 리턴하는 함수 생성
export const increase = () => {
return {
type: "INCREASE"
};
};
export const decrease = () => {
return {
type: "DECREASE"
};
};
const count = 1;
// reducer 생성
const reducer = (state = count, action) => {
switch (action.type) {
//action === 'INCREASE'일 경우
case "INCREASE":
return state + 1;
// action === 'DECREASE'일 경우
case "DECREASE":
return state - 1;
// action === 'SET_NUMBER'일 경우
case "SET_NUMBER":
return action.payload;
// 해당 되는 경우가 없을 땐 기존 상태를 그대로 리턴
default:
return state;
}
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
const store = createStore(reducer);
root.render(
<Provider store={store}>
<App />
</Provider>
};
dispatch는 reducer로 action 객체를 전달하는 역할을 합니다.
// [예시]
const action = {type:"DECREASE", payload:5};
dispatch({type:"INCREASE", payload:5}); // reducer 함수로 {type:"INCREASE", payload:5} 전달
dispatch(action); // reducer 함수로 {type:"DECREASE", payload:5} 전달
react-redux 라이브러리는 action 객체를 reducer에 전달하고 변경된 state의 변경사항을 다른 컴포넌트에게 알려주는 역할을 하는 hooks를 지원합니다. useDisptch로 action 객체를 전달하고 useSelector를 통해 변경된 state를 가져올 수 있습니다.
아래의 코드에서 버튼을 눌려서 함수를 실행 시키면 함수 내부의 dispatch 함수가 increase 또는 decrease 함수가 리턴한 action 객체을 reducer 함수로 전달합니다.
// App.js
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { increase, decrease } from "./index";
export default function App() {
// store 객체의 dispatch 역할
const dispatch = useDispatch(); // store의 dispatch 함수 사용
// store 객체의 getState() 역할
const state = useSelector((state) => state); // store에 있는 state 값을 가져오는 역할
const plusNum = () => {
dispatch(increase());
};
const minusNum = () => {
dispatch(decrease());
};
return (
<div className="container">
<h1>{`Count: ${state}`}</h1>
<div>
<button className="plusBtn" onClick={plusNum}>
+
</button>
<button className="minusBtn" onClick={minusNum}>
-
</button>
</div>
</div>
);
}
프로젝트가 커지면서 다루어야하는 상태값이 많아 질경우 로직에 따라서 여러가지 reducer를 사용해야하는 경우 combineReducers를 사용할 수 있습니다. 아래는 todoApp을 만드는 애플리케이션의 예시 코드입니다.
우선 reducer로 전달할 action 객체를 리턴하는 함수들을 정의하고 export 합니다.
// action.js
export const ADD_TODO = "ADD_TODO";
export const COMPLETE_TODO = " COMPLETE_TODO ";
export const SHOW_ALL = "SHOW_ALL";
export const SHOW_COMPLETE = "SHOW_COMPLETE";
// {type : ADD_TODO, text : '할일'}
export function addTodo(text) {
return {
type: ADD_TODO,
text: text,
};
}
//{type: COMPLETE_TODO,. index : 3}
export function completeTodo(index) {
return {
type: SHOW_ALL,
index: index,
};
}
//{type: COMPLETE_TODO,. index : 3}
export function showCompletel() {
return {
type: SHOW_COMPLETE,
};
}
export function showAll() {
return {
type: SHOW_ALL,
};
}
combineReducers를 이용하여 todos와 filter라는 두 가지 reducer를 결합할 수 있습니다. 전달된 action의 type값이 무엇이냐에 따라 todos 또는 filter 중 해당하는 reducer 함수로 전달되어 state값을 변경시킵니다.
// reducer.js
import { combineReducers } from "redux";
import todos from "./todos";
import filter from "./filter";
const reducer = combineReducers({ todos: todos, filter: filter });
export default reducer;
action.type 값이 ADD_TODO 또는 COMPLETE_TODO 인경우 todos reducer가 동작합니다.
// todos.js
import { ADD_TODO, COMPLETE_TODO } from "./actions";
const initialState = [];
// todosInitialState = [{text : '코딩', done : false}, {text : '점심먹기', done : false}]
export default function todos(previousState = initialState, action) {
if (action.type === ADD_TODO) {
return [...previousState, { text: action.text, done: false }];
}
if (action.type === COMPLETE_TODO) {
return previousState.todos.map((todo, index) => {
if (index === action.index) {
return { ...todo, done: true };
}
return todo;
});
}
return previousState;
}
action.type 값이 SHOW_ALL 또는 SHOW_COMPLETE 인경우 filter reducer가 동작합니다.
// filter.js
import { SHOW_ALL, SHOW_COMPLETE } from "./actions";
const initialState = "ALL";
export default function filter(previousState = initialState, action) {
if (action.type === SHOW_COMPLETE) {
return "COMPLETE";
}
if (action.type === SHOW_ALL) {
return "ALL";
}
return previousState;
}
위의 코드처럼 기능에 따른 reducer를 나누어서 작성하면 코드를 유지보수하는데 효율적입니다.