리덕스 툴킷 ( Redux-Toolkit + TypeScript + React )
포스트
취소

리덕스 툴킷 ( Redux-Toolkit + TypeScript + React )

해당 포스트는 React + Redux-Toolkit + TypeScript의 사용법에 대한 포스트입니다.
순수 Redux나 사용 이유에 대한 내용은 해당 링크를 참고해주세요!

🗒️ 설치

1
2
3
npm install react-redux @reduxjs/toolkit

npm install -D @types/react-redux 

📜 Redux-Toolkit 사용 설명서

0️⃣ createAction ( redux-action )

쉽게 Action을 만들어주는 헬퍼 함수입니다.

1
2
3
4
5
6
7
import { createAction } from "@reduxjs/toolkit";

// 액션 크리에이터 생성
const actionCreator = createAction("MYACTION", (n: number) => ({ payload: n }));

console.log(actionCreator.type); // "MYACTION"
console.log(actionCreator(1)); // {type: 'MYACTION', payload: 1}

1️⃣ createReducer

쉽게 Reducer를 만들어주는 헬퍼 함수입니다.
객체 혹은 함수 형태로 Reducer를 정의할 수 있지만, RTK에서 사용하기 편한 함수 형태로 예시를 작성하겠습니다.
( Reducer를 정상적으로 동작하게 하기 위해서 필요한 다른 것들이 필요해서 일단 다 추가하고 이후에 각각 나눠서 설명하겠습니다. )

TODO를 입력하는 간단한 리듀서입니다.
( TODOstring[] )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { createAction, createReducer } from "@reduxjs/toolkit";
import type { AnyAction, PayloadAction } from "@reduxjs/toolkit";

const TODO = "TODO";

/** "TODO"의 액션 크리에이터 */
export const todoCreator = createAction(TODO, (todo: number) => ({ payload: { todo } }));

/** 초깃값 타입 */
type InitialState = { todos: string[] };

/** 초깃값 */
const initialState: InitialState = { todos: [] };

/** "addCase"가 아니면서 "action"으로 들어오는 "payload"가 정성적인 타입인지 확인 ( 사용자 정의 타입 가드 ) */
const isActionWithStringPayload = (action: AnyAction): action is PayloadAction<string> => typeof action.payload === "string";

/** 리듀서 정의 */
const todoReducer = createReducer(initialState, (builder) => {
  /** todo 추가 */
  builder.addCase(todoCreator, (state, action) => {
    return {
      ...state,
      todos: [...state.todos, action.payload.todo + ""],
    };
  });

  /** "addCase"에 맞지 않으며 작성한 조건("isActionWithStringPayload")에 맞지 않는 액션이 들어오는 경우 처리 */
  builder.addMatcher(isActionWithStringPayload, (state, action) => {
    console.log("매칭 >> ", state, action);

    return { ...state };
  });

  /** "addCase", "addMatcher"에 맞지 않는 경우 처리 */
  builder.addDefaultCase((state, action) => {
    console.log("예외 >> ", state, action);

    return { ...state };
  });
});

export default todoReducer;

2️⃣ combineReducers

여러 Reducer를 합치는 헬퍼 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { combineReducers } from "@reduxjs/toolkit";

// 모든 "Reducer"가 있다고 가정
import counterReducer from "./counter";
import userReducer from "./user";
import todoReducer from "./todo";

const rootReducer = combineReducers({
  counter: counterReducer,
  user: userReducer,
  todo: todoReducer,
});

export default rootReducer;

3️⃣ immer

immer는 내부적으로 돌아가기 때문에 아래 예시를 테스트하려면 설치해야 합니다.

원래 Reduxstate를 변경할 때는 기존 값과 비교를 위해 원본을 수정하는 행위((1))를 하면 안됩니다.
즉, (2)처럼 기존 데이터는 그대로 두고 새로운 데이터를 수정한 값을 상태 변경 함수에 넘겨줘야 합니다.

하지만 immer를 사용하면 (3)처럼 원본을 수정하는 행위를 해도 내부적으로 불변성을 유지하도록 조작해줍니다.
( Array.prototype.push 같은 메서드를 사용해도 됩니다. )

아직 immer에 대해 제대로 알지는 못하기 때문에 설명은 여기까지만 적겠습니다.
일단은 내부적으로 사용하기 때문에 Redux-Toolkit에서는 원본을 마음대로 변경해도 된다고 생각하면 될 것 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import produce from "immer";

const [state, setState] = useState({ count: 1 });

// (1)
state.count += 1;
// (2)
setState(prev => ({ ...prev, count: prev.count + 1 }));

// (2)
const increase = produce(state, draft => {
  draft.number += 1;
});

console.log(increase); // { count: 2 }

4️⃣ configureStore + custom middleware

Reducer들을 합쳐서 store를 만드는 헬퍼 함수입니다.
(1), (2), (3) 미들웨어를 등록하는 방법입니다.
하지만 (1), (2)를 사용하는 경우 미들웨어를 거치면서 타입 추론에 대한 문제가 발생합니다.
제 기준으로 (1), (2) 그리고 (3)prepend()를 사용하면 createAsyncThunk()dispatch()할 때 타입 추론에 대한 오류가 발생합니다.

정확하게 무슨 문제인지는 모르겠지만, 공식 문서에는 (2)를 사용하면 미들웨어를 거치는 동안 타입 추론을 못한다고 하네요.
( 일단 저는 (3)으로만 사용하는 편입니다. )

그리고 미들웨어를 직접 만들 때는 Middleware 타입을 이용하고 아래와 같은 형태로 사용하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { configureStore, MiddlewareArray } from "@reduxjs/toolkit";
import type { Middleware } from "@reduxjs/toolkit";

import rootReducer from "./reducers";

// (4) 커스텀 미들웨어 "logger"
const myLogger: Middleware = (store) => (next) => (action) => {
  console.group("logger");
  console.log("type >> ", action.type);
  console.log("payload >> ", action.payload);
  console.groupEnd();

  return next(action);
};

const store = configureStore({
  reducer: rootReducer,
  // (1)
  // middleware: new MiddlewareArray().prepend([]).concat([]),

  // (2)
  // middleware: [myLogger],

  // (3)
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat([myLogger]),
});

export { store };

5️⃣ createSelector ( reselect )

컴포넌트에서 Redux의 데이터를 가져올 때 memoization을 적용해주는 헬퍼 함수입니다.

useSelector()는 기본적으로 렌더링마다 실행되기 때문에 최적화를 해주면 성능 향상이 될 수 있습니다.
createSelector()를 이용하면 useSelector(callback)의 매개변수로 전달되는 값이 바뀌지 않으면 memoization한 값을 그대로 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { createSelector } from "@reduxjs/toolkit";
import store from "../configureStore";

/** "store"에서 필요한 "state" 선택 */
const getTodos = (myStore: ReturnType<typeof store.getState>) => {
  return myStore.todo;
};

/** 선택한 "state"에서 메모이제이션할 값 선택 ( 캐싱 ) */
export const getTodoList = createSelector(getTodos, (todos) => {
  // 테스트용 ( 정상적으로 동작한다면 즉, 메모이제이션 된다면 초기와 "todos"가 바뀌는 경우를 제외하고 콘솔이 호출되지 않음 )
  console.log("메모이제이션 안됨!!!");

  return todos;
});

// 특정 컴포넌트에서 아래와 같이 사용
const { todos } = useSelector(getTodoList);

6️⃣ 타입 적용하기 ( dispatch, selector )

일반적인 useDispatch(), useSelector()를 사용하면 TypeScript에서 Redux의 타입을 추론하지 못합니다.
따라서 아래와 같은 방법을 적용한 useAppSelector()useAppDispatch()를 사용하면 타입을 적용한 채로 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useDispatch, useSelector } from "react-redux";

// store
import store from "@src/store/configureStore";

// type
import type { TypedUseSelectorHook } from "react-redux";

type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;

/**
 * 작성한 타입이 적용된 "useSelector()"
 */
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
/**
 * 작성한 타입이 적용된 "useDispatch()"
 */
export const useAppDispatch: () => AppDispatch = useDispatch;

7️⃣ createSlice

위에서 공부했던 createAction, createReducer, createSelector, immer를 한 번에 처리해주는 헬퍼 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { createSlice } from "@reduxjs/toolkit";

// type
import type { PayloadAction } from "@reduxjs/toolkit";

const initialState = {
  count: 0,
};

const counterSlice = createSlice({
  // 액션을 생성하는데 사용
  name: "counter",

  // 초기 상태
  initialState,

  // 리듀서 정의
  reducers: {
    // "PayloadAction"를 이용해서 "payload"타입 지정
    increment(state, action: PayloadAction<number>) {
      state.count += action.payload;
    },
    decrement: (state, action: PayloadAction<number>) => {
      state.count -= action.payload;
    },
  },
  // 비동기적인 처리를 하는 부분
  extraReducers(builder) {
    // ...
  },
});

// 아래 값을 이용해서 "Action"을 생성
// 즉, "dispatch(counterAction.increment(1))"과 같은 형태로 사용
export const counterAction = counterSlice.actions;

// 리듀서 합칠 때 사용
export default counterSlice.reducer;

8️⃣ createAsyncThunk

Redux에서 비동기를 처리할 때 createAsyncThunk()를 사용합니다.
createAsyncThunk<성공반환타입, 매개변수타입, 옵션타입(실패반환타입)> 형태로 타입을 지정할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

// =================== type ===================
type User = {
  id: number;
  name: string;
  // ... 생략
};

// =================== state ===================
// 초기 상태의 타입
type InitialState = {
  users: User[];
  user: User | null;
};

// 초기 상태
const initialState: InitialState = {
  users: [],
  user: null,
};

// =================== api type ===================
type ApiFetchUsersRequest = {};
type ApiFetchUsersResponse = User[];
type ApiFetchUserRequest = { id: number };
type ApiFetchUserResponse = User;

type ApiFetchUsersHandler = (
  body: ApiFetchUsersRequest
) => Promise<ApiFetchUsersResponse>;
type ApiFetchUserHandler = (
  body: ApiFetchUserRequest
) => Promise<ApiFetchUserResponse>;

// =================== api 요청 함수 ===================
const apiFetchUsers: ApiFetchUsersHandler = async () =>
  fetch(`https://jsonplaceholder.typicode.com/users`).then((res) => res.json());
const apiFetchUser: ApiFetchUserHandler = async ({ id }) =>
  fetch(`https://jsonplaceholder.typicode.com/users`).then((res) => res.json());

// "createAsyncThunk"의 예외로 사용할 타입
type CreateAsyncThunkErrorType = { rejectValue: { message: string } };

// =================== thunk ===================
/** 모든 유저들 패치 */
export const usersThunk = createAsyncThunk<
  { users: User[] },
  null,
  CreateAsyncThunkErrorType
>(
  "users",
  async (_, { rejectWithValue }) => {
    try {
      const users = await apiFetchUsers({});

      return { users };
    } catch (error) {
      return rejectWithValue({ message: "유저들 검색 실패" });
    }
  }
);
/** 특정 유저 패치 */
export const userThunk = createAsyncThunk<
  { user: User },
  { id: number },
  CreateAsyncThunkErrorType
>(
  "user",
  async ({ id }: { id: number }, { rejectWithValue }) => {
    try {
      const user = await apiFetchUser({ id });

      return { user };
    } catch (error) {
      console.error(error);

      return rejectWithValue({ message: "유저 검색 실패" });
    }
  }
);

// =================== slice ===================
const userSlice = createSlice({
  name: "user",
  initialState,
  reducers: {
    // 동기적인 처리를 하는 부분
  },
  // 비동기적인 처리를 하는 부분
  extraReducers(builder) {
    // 패치중
    builder.addCase(usersThunk.pending, (state, action) => {
      console.log("유저들 패치중");
    });
    // 패치 성공
    builder.addCase(usersThunk.fulfilled, (state, action) => {
      // "usersThunk()"의 두 번째 인자인 함수에서 반환하는 값이 "action.payload"로 들어옴 ( 타입 추론됨 )
      state.users = action.payload.users;
    });
    // 패치 실패
    builder.addCase(usersThunk.rejected, (state, action) => {
      // "usersThunk()"의 두 번째 인자인 함수에서 "rejectWithValue()"를 사용한 값이 "action.payload"로 들어옴 ( "createAsyncThunk"에서 제네릭을 작성하면 타입 추론됨 )
      console.error("error >> ", action.payload?.message);
    });

    builder.addCase(userThunk.pending, (state, action) => {
      console.log("유저 패치중");
    });
    builder.addCase(userThunk.fulfilled, (state, action) => {
      state.user = action.payload.user;
    });
    builder.addCase(userThunk.rejected, (state, action) => {
      console.error("error >> ", action.payload?.message);
    });
  },
});

export const userAction = userSlice.actions;

export default userSlice.reducer;

8️⃣ 전체 사용 예시

0 ~ 5에서 설명한 내용을 기반으로 Redux-Toolkit을 사용한 예시 코드입니다.
현재 stackblitz에서는 이상하게 타입 오류가 발생하는데 실제로 VSCode에서 그대로 실행하면 정상적으로 동작합니다.
( 오류를 추적해보면 여기에 도달하는데 immer, reselect, redux-thunk을 찾을 수 없다고 하는데 기본적으로 RTK에 내장되어 있는 걸로 알고 있고, 직접 설치해도 오류가 사라지지는 않습니다… 😥 )

📮 레퍼런스

  1. Redux-Toolkit 공식 문서 - TypeScript Quick Start
  2. Redux-Toolkit 공식 문서 - Usage With TypeScript
  3. 1-blue - memoization
  4. stackblitz - Redux-Toolkit 사용 예시
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.