Redux store 는 앱을 구성하는 state, action, reducer 를 통합한다.
store.getState()
를 통해 현재 상태에 접근할 수 있다.store.dispatch(action)
를 통해 상태를 업데이트할 수 있다.store.subscribe(listener)
를 통해 리스터 콜백을 등록한다.store.subscribe(listener)
로 반환된 함수를 unsubscribe 할 수 있다.Redux 앱에서 하나의 store 만 가져야 한다. 만약 로직을 분리하고 싶다면 store 를 분리하는게 아니라 reducer composition 을 사용하여 다수의 reducer를 생성하고 결합하여 사용한다.
이전 state, action, reducer 편에서 다수의 reducer 를 combineReducers 를 통해 하나의 root reducer 를 만들어줬다.
// src/store.js
import { createStore } from 'redux'
import rootReducer from './reducer'
const store = createStore(rootReducer)
export default store
root reducer 를 createStore 의 인자로 전달하여 store 를 생성해줄 수 있다.
createStore 는 두번째 인자로 초기 상태를 담은 배열을 전달할 수 있다.
createStore(rootReducer, preloadedState)
import { createStore } from 'redux'
function todos(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return state.concat([action.text])
default:
return state
}
}
const store = createStore(todos, ['Use Redux'])
store.dispatch({
type: 'ADD_TODO',
text: 'Read the docs'
})
console.log(store.getState())
// [ 'Use Redux', 'Read the docs' ]
이제 store 까지 만들어줬다. 그럼 프로그램이 동작하게 만들어보자.
dispatch
는 파라미터로 액션을 받아 상태를 업데이트할 수 있게 해준다
// src/index.js
// Omit existing React imports
import store from './store'
// Log the initial state
console.log('Initial state: ', store.getState())
// {todos: [....], filters: {status, colors}}
// Every time the state changes, log it
// Note that subscribe() returns a function for unregistering the listener
const unsubscribe = store.subscribe(() =>
console.log('State after dispatch: ', store.getState())
)
// Now, dispatch some actions
store.dispatch({ type: 'todos/todoAdded', payload: 'Learn about actions' })
store.dispatch({ type: 'todos/todoAdded', payload: 'Learn about reducers' })
store.dispatch({ type: 'todos/todoAdded', payload: 'Learn about stores' })
store.dispatch({ type: 'todos/todoToggled', payload: 0 })
store.dispatch({ type: 'todos/todoToggled', payload: 1 })
store.dispatch({ type: 'filters/statusFilterChanged', payload: 'Active' })
store.dispatch({
type: 'filters/colorFilterChanged',
payload: { color: 'red', changeType: 'added' }
})
// Stop listening to state updates
unsubscribe()
// Dispatch one more action to see what happens
store.dispatch({ type: 'todos/todoAdded', payload: 'Try creating a store' })
// Omit existing React rendering logic
store 생성은 toolkit 을 사용할 경우 configureStore
로 할 수 있고 redux 의 경우 createStore
로 할수 있다. 이때 createStore 가 이전 포스트에서는 root reducer, 초기 상태인 preloadedState 를 인자로 받을 수 있다고 했는데 세번째 인자로 enhancer 를 받을 수도 있다.
공식 문서의 enhancer 에 대한 설명
- 미들웨어나 시간여행, 영속성 등의 서드파티 기능을 저장소에 추가하기 위해 지정가능하다.
음.. 역시 설명을 보고 이해가 하나도 안되었다.
그나마 코드 예시를 보고 대강 이런거구나 라는 생각이 들었는데 Redux store 는 store enhancer 를 사용하여 커스터마이징 할 수 있다. store enhancer 는 createStore 의 특별한 버전으로 Redux store 원본을 감싸는 또하나의 layer 를 추가할 수 있다. 그렇게 enhanced 된 store 는 store 가 dispatch, getState, subscribe 함수등을 사용하는 행위를 어느정도 변경할 수 있다.
enhancer 는 dispatch 하는동안 추가적으로 어떤 동작을 할 수 있도록 지정할 수 있다. 예를 들어 dispatch 할때 인사하는 로그를 남기도록 한다면
// src/store.js
import { createStore } from 'redux'
import rootReducer from './reducer'
import { sayHiOnDispatch } from './exampleAddons/enhancers'
const store = createStore(rootReducer, undefined, sayHiOnDispatch)
export default store
// src/index.js
import store from './store'
console.log('Dispatching action')
store.dispatch({ type: 'todos/todoAdded', payload: 'Learn about actions' })
console.log('Dispatch complete')
이러면 dispatch 할때 Hi
라는 로그가 남는다. enhancer 도 하나만 존재해야 하는데 만약 여러개가 존재할 경우 combineReducer 처럼 redux 의 compose 메서드를 사용하여 여러 enhancer 를 하나로 합쳐줄 수 있다.
// src/store.js
import { createStore, compose } from 'redux'
import rootReducer from './reducer'
import {
sayHiOnDispatch,
includeMeaningOfLife
} from './exampleAddons/enhancers'
const composedEnhancer = compose(sayHiOnDispatch, includeMeaningOfLife)
const store = createStore(rootReducer, undefined, composedEnhancer)
export default store
전체 코드
실행 결과
프로젝트 구조
.
├── README.md
├── package-lock.json
├── package.json
├── public
├── src
│ ├── App.css
│ ├── App.js
│ ├── App.test.js
│ ├── app
│ │ └── store.js
│ ├── features
│ │ ├── filters
│ │ │ ├── Filter.jsx
│ │ │ └── filtersSlice.js
│ │ └── todos
│ │ ├── Todo.jsx
│ │ └── todosSlice.js
│ ├── index.css
│ ├── index.js
│ ├── logo.svg
│ ├── reportWebVitals.js
│ └── setupTests.js
└── yarn.lock
// src/features/todos/todosSlice.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
todos: [
{ id: 0, text: "Learn React", completed: true },
{ id: 1, text: "Learn Redux", completed: false },
],
};
// Create a utility function to generate the next todo ID
function nextTodoId(todos) {
const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1);
return maxId + 1;
}
export const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
todoAdded: (state, action) => {
state.todos.push({
id: nextTodoId(state.todos),
text: action.payload,
completed: false,
});
},
todoToggled: (state, action) => {
const toggledTodo = state.todos.find(
(todo) => todo.id === action.payload
);
if (toggledTodo) {
toggledTodo.completed = !toggledTodo.completed;
}
},
todoDeleted: (state, action) => {
state.todos = state.todos.filter((todo) => todo.id !== action.payload);
},
allCompleted: (state) => {
state.todos = state.todos.map((todo) => ({
...todo,
completed: true,
}));
},
completedCleared: (state) => {
state.todos = state.todos.filter((todo) => !todo.completed);
},
},
});
export const maxId = (todos) =>
todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1;
export const todos = (state) => state.todos;
export const completedTodos = (state) =>
state.todos.filter((todo) => todo.completed === true);
export const {
todoAdded,
todoToggled,
todoDeleted,
allCompleted,
completedCleared,
} = todosSlice.actions;
export default todosSlice.reducer;
//src/features/filters/filtersSlice.js
import { createSlice } from "@reduxjs/toolkit";
export const StatusFilters = {
All: "all",
Active: "active",
Completed: "completed",
};
const initialState = {
status: StatusFilters.All,
};
export const filtersSlice = createSlice({
name: "filters",
initialState,
reducers: {
statusFilterChanged: (state, action) => {
state.status = action.payload;
},
},
});
export const filterStatus = (state) => state.filters.status;
export const { statusFilterChanged } = filtersSlice.actions;
export default filtersSlice.reducer;
// src/features/todos/Todo/jsx
import { useSelector, useDispatch } from "react-redux";
import { useState } from "react";
import {
todoAdded,
todoToggled,
todoDeleted,
allCompleted,
completedCleared,
todos,
} from "./todosSlice";
import {
statusFilterChanged,
filterStatus,
StatusFilters,
} from "../filters/filtersSlice";
export function Todo() {
const dispatch = useDispatch();
const todoList = useSelector(todos).todos;
const currentStatus = useSelector(filterStatus);
const [textInput, setTextInput] = useState("");
const resultTodos = (currentStatus) => {
switch (currentStatus) {
case StatusFilters.Active:
return todoList.filter((todo) => todo.completed === false);
case StatusFilters.Completed:
return todoList.filter((todo) => todo.completed === true);
default:
return todoList;
}
};
const titleStyle = {
padding: "15px",
background: "antiquewhite",
fontWeight: "bold",
fontSize: "x-large",
};
const itemContainerStyle = {
display: "grid",
gridTemplateColumns: "1fr 14fr 1fr",
padding: "10px 0",
};
const footerContainerStyle = {
display: "flex",
justifyContent: "space-between",
padding: "10px",
borderBottom: "0.5px solid black",
};
return (
<>
<div style={titleStyle}>TODO 앱 만들기</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
margin: "10px",
}}
>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
placeholder="할일 추가하기"
style={{ width: "93vw" }}
/>
<button
onClick={() => {
dispatch(todoAdded(textInput));
setTextInput("");
}}
>
add
</button>
</div>
<ul>
{resultTodos(currentStatus).map((todo) => (
<li key={todo.id} style={itemContainerStyle}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(todoToggled(todo.id))}
id={todo.id}
/>
<label htmlFor={todo.id} style={{ padding: "5px" }}>
{todo.text}
</label>
<button onClick={() => dispatch(todoDeleted(todo.id))}>x</button>
</li>
))}
</ul>
<div style={footerContainerStyle}>
<div>
<button onClick={() => dispatch(allCompleted())}>
모두 체크하기
</button>
<button onClick={() => dispatch(completedCleared())}>
완료한 일 모두 삭제하기
</button>
</div>
<select
name="status"
onChange={(e) => {
dispatch(statusFilterChanged(e.target.value));
}}
value={currentStatus}
>
<option value="all">모두 보기</option>
<option value="active">할 일 보기</option>
<option value="completed">완료한 일 보기</option>
</select>
</div>
</>
);
}
// src/app/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
import todosReducer from "../features/todos/todosSlice";
import filtersReducer from "../features/filters/filtersSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer,
filters: filtersReducer,
},
});
// src/index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
import reportWebVitals from './reportWebVitals';
import './index.css';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
reportWebVitals();
redux toolkit 의 createSlice 를 통해 reducer, action creator, action 폴더를 따로 만들지 않고도 하나의 slice 파일에서 모든 것을 관리할 수 있던 부분과 unmutable 하게 update 해야 하는 것을 createSlice 에 내장되어 있는 Immer 라이브러리 덕분에 mutable 하게 update 하듯 push 등을 사용할 수 있던 점이 정말 편했다.
또한 feature 별로 디렉토리를 구분하여 nested 한 구조가 좀더 알아보기 쉽게 구성할 수 있었다.
직접 todo 앱을 만들어보면서 reducer, action, dispatch 등을 통해 전역적으로 상태를 어떻게 관리할 수 있는지 확실히 알수 있게 되었다.
러닝커브가 좀 있어서 개념을 이해하는데 다소 시간이 걸렸지만 props 로 state 를 전달하는 것보다 전역적으로 상태를 관리하는 부분이 useReducer 와 비슷하면서도 toolkit 의 편리함을 느낄 수 있었다.
다음엔 recoil 로 todo 앱을 만들어보며 전역상태를 관리하는 다른 방법에 대해 알아볼 계획이다.