본문으로 건너뛰기

Redux Toolkit 첫 도입기 — ToDo로 7단계 깔아본 작업 정리

·7 min read

ToDo 리스트를 만들면서 Redux Toolkit을 처음부터 끝까지 한 번 깔아봤다. 스토어를 만들고, 타입을 잡고, 리듀서·액션을 붙이고, 컴포넌트에서 셀렉터·디스패치를 쓰는 데까지 7단계로 끊어 적은 작업기다.

1. configureStore로 스토어 껍데기부터 만든다

가장 먼저 한 일은 빈 스토어를 띄우는 것이었다. 리듀서는 일단 비워둔다.

import { configureStore } from "@reduxjs/toolkit"
 
export const store = configureStore({
  reducer: {},
});

이 시점엔 reducer가 비어 있다. 일단 껍데기부터 잡고 나머지를 붙여 나갈 생각이었다.

2. App 상위에 Provider를 끼운다

스토어를 컴포넌트 트리에 연결하려면 react-reduxProvider(스토어를 하위 컴포넌트에 내려주는 컨텍스트 래퍼)로 App을 감싼다.

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

이 시점 프로젝트는 React 17이라 ReactDOM.render로 그대로 마운트했다. React 18이 한 달 반쯤 전에 나왔지만 이번엔 굳이 createRoot로 옮기지 않았다.

3. 타입을 미리 잡아두면 뒤가 편하다

리듀서를 만들기 전에 타입부터 잡았다. 액션마다 페이로드 모양이 다를 거라 베이스 인터페이스 하나에 페이로드 타입을 덧붙이는 식으로 짰다.

type ToDo = {
  id: number;
  content: string;
  checked: boolean;
};
 
type ToDoList = ToDo[] | [];
 
interface IToDoListState {
  toDoList: ToDoList;
}
 
interface IAction {
  type?: string;
}
 
interface IToggleToDoPayload {
  id: number;
  checked: boolean;
}
 
interface IToggleToDoAction extends IAction {
  payload: IToggleToDoPayload;
}
 
interface IDeleteToDoPayload {
  id: number;
}
 
interface IDeleteToDoAction extends IAction {
  payload: IDeleteToDoPayload;
}

나중에 알게 됐는데 Redux Toolkit은 createAction<Payload> + builder.addCase 조합으로 case reducer의 action 인자가 PayloadAction<Payload>로 자동 추론되도록 설계돼 있다. 이런 인터페이스를 직접 만들 필요가 없다. 처음 깔 때는 그 흐름을 몰라서 직접 다 적었다.

4. 액션과 리듀서를 한 파일에 같이 만든다

슬라이스 단위 파일(store/toDoList/index.ts) 하나에 액션 크리에이터, 초기 상태, 케이스 리듀서, 합쳐진 리듀서까지 같이 두는 구성으로 갔다.

import { createAction, createReducer } from "@reduxjs/toolkit";
 
export const action = {
  toggleToDo: createAction<IToggleToDoPayload>("TOGGLE/TO_DO"),
  deleteToDo: createAction<IDeleteToDoPayload>("DELETE/TO_DO"),
};
 
const initialState: IToDoListState = {
  toDoList: [],
};
 
export const reducer = {
  toggleToDo: (state: IToDoListState, action: IToggleToDoAction) => {
    const todo = state.toDoList.find((t: ToDo) => t.id === action.payload.id);
    if (todo) {
      todo.checked = action.payload.checked;
    }
  },
  deleteToDo: (state: IToDoListState, action: IDeleteToDoAction) => {
    state.toDoList = state.toDoList.filter((todo: ToDo) => todo.id !== action.payload.id);
  },
};
 
const toDoListReducer = createReducer(initialState, builder => {
  builder
    .addCase(action.toggleToDo, reducer.toggleToDo)
    .addCase(action.deleteToDo, reducer.deleteToDo);
});
 
export default toDoListReducer;

첫째, Redux Toolkit의 createReducer/createSlice는 내부적으로 Immer(상태를 직접 수정하는 것처럼 적으면 자동으로 불변 업데이트로 바꿔주는 라이브러리)를 쓴다. 그래서 state.toDoList를 마치 mutation처럼 직접 수정해도 실제로는 새 객체가 만들어진다. 처음엔 "이렇게 적어도 되는 게 맞나" 싶어서 한 번 더 확인했다.

둘째, find는 일치하는 게 없으면 undefined를 돌려준다. find(...).checked = ...로 바로 대입하면 일치하는 todo가 없을 때 Cannot read properties of undefined 런타임 에러가 난다. if (todo) 가드를 한 줄 끼우는 게 안전했다.

5. 만든 리듀서를 스토어에 꽂고 타입을 같이 export한다

1번에서 비워뒀던 스토어로 돌아와 방금 만든 리듀서를 끼웠다. 컴포넌트 쪽에서 셀렉터·디스패치 타입이 매번 필요할 거라 RootState/AppDispatch도 같이 export 해뒀다.

import { configureStore } from "@reduxjs/toolkit";
 
import toDoListReducer from "src/store/toDoList";
 
export const store = configureStore({
  reducer: {
    toDoList: toDoListReducer,
  },
});
 
export type RootState = ReturnType<typeof store.getState>;
 
export type AppDispatch = typeof store.dispatch;

이 두 타입을 store 파일에서 같이 export하는 패턴은 Redux Toolkit 공식 TypeScript 가이드 그대로 베껴 적었다. 한 번에 만들어두면 컴포넌트에서 셀렉터 타입을 잡을 때 import 한 줄로 끝난다.

6. useSelector로 state를 읽는다

리스트 컴포넌트에서 useSelector(스토어 state에서 필요한 조각만 뽑아오는 react-redux 훅)로 상태를 읽었다.

import { useSelector } from "react-redux";
 
import Item from "src/components/toDoList/item";
import { RootState } from "src/store";
 
function List() {
  const toDoList: ToDoList = useSelector((state: RootState) => state.toDoList.toDoList);
 
  return (
    <div>
      {toDoList && toDoList.map(toDo => <Item key={toDo.id} toDo={toDo} />)}
    </div>
  );
}
 
export default List;

(state: RootState) => 캐스팅을 매번 적는 게 번거롭다. react-redux 공식 가이드는 store 파일에서 useAppSelector: TypedUseSelectorHook<RootState> = useSelector를 한 번 만들어두고 컴포넌트에서는 useAppSelector만 import해서 쓰는 패턴을 권장한다. 이번엔 일단 흐름에 익숙해질 때까지 직접 캐스팅 방식으로 적었다.

7. dispatch는 useDispatch로 그대로 넘긴다

아이템 컴포넌트에서 토글·삭제 액션을 디스패치했다.

import { useDispatch } from "react-redux";
 
import { action } from "src/store/toDoList";
 
interface IProps {
  toDo: ToDo;
}
 
function Item({ toDo }: IProps) {
  const { id, content, checked } = toDo;
  const dispatch = useDispatch();
 
  const onToggle = (event: any) => {
    dispatch(action.toggleToDo({ id, checked: event.target.checked as boolean }));
  };
 
  const onDeleteToDo = () => {
    dispatch(action.deleteToDo({ id }));
  };
 
  return (
    <div>
      <div>{id}</div>
      <p>{content}</p>
      <input type="checkbox" defaultChecked={checked} onChange={onToggle} />
      <button onClick={onDeleteToDo}>삭제</button>
    </div>
  );
}
 
export default Item;

createAction으로 만든 액션 크리에이터는 호출하면 그대로 { type, payload } 액션 객체를 돌려준다. 그래서 dispatch(action.toggleToDo({...})) 형태로 인자 없이 결과를 그대로 넘기면 된다.

이 순서대로 한 번 깔아두니 다음 슬라이스를 추가할 때부터는 4번 파일만 복사해서 액션 이름과 리듀서 로직을 갈아끼우면 됐다.