# redux-toolkit

# 사용하는 이유

redux를 아무 라이브러리 없이 사용할 때 (actionType 정의 -> 액션 함수 정의 -> 리듀서 정의) 1개의 액션을 생성합니다. 이렇게 필요하지만 너무 많은 코드가 생성되니 redux-actons라는 것을 사용하게 되었고, 불변성을 지켜야하는 원칙 때문에 immer를 사용하게되고, store 값을 효율적으로 핸들링하여 불필요 리렌더링을 막기 위해 reselect를 쓰게 되었으며, 비동기를 수월하게 하기위해, thunk나 saga를 설치하여 redux를 더 효율적으로 사용하게 됩니다. 지금 말한 것만 총 4~5개의 라이브러리를 설치하여야 위처럼 사용할 수 있습니다.

그런데, redux-toolkit은 redux가 공식적으로 만든 라이브러리로, saga를 제외한 위 기능 모두 지원합니다. 또한 typeScript 사용자를 위해 action type, state type 등 TypeScript를 사용할 때 필요한 Type Definition을 공식 지원합니다. 어떻게 사용하는지 아래를 통해 알아봅시다.

# 지원하는 기능

  1. redux-action
  2. reselect
  3. immer의 produce
  4. redux-thunk
  5. Flux Standard Action 강제화
  6. Type Definition

# redux-action

redux-action에서 사용했던 createAction를 지원합니다. 원래 사용하시던 대로 아래와 같이 사용하시면 됩니다.

const increment = createAction("INCREMENT");
const decrement = createAction("DECREMENT");

function counter(state = 0, action) {
  switch (action.type) {
    case increment.type:
      return state + 1;
    case decrement.type:
      return state - 1;
    default:
      return state;
  }
}

const store = configureStore({
  reducer: counter
});

document.getElementById("increment").addEventListener("click", () => {
  store.dispatch(increment());
});
  • 저의 경우 createSlice라는 기능을 사용합니다. 이 기능을 사용하면 createAction을 통해 따로 액션타입을 정의하지 않아도 자동으로 액션타입을 만들어줍니다.

# createSlice

예제는 다음과 같으며 setTitle 함수를 실행하면 action.type = 'todo/setTitle', payload = {name: xxx, content: xxx}로 실행됩니다.

const name = "todo";
type stateType = {
  title: { name: string; content: number };
};

const initialState: stateType = {
  title: { name: "ttttt", content: 0 },
};

export const todoSlice = createSlice({
  name,
  initialState,
  reducers: {
    setTitle: (
      state,
      action: PayloadAction<{ name: string; content: string }>
    ) => {
      state.title.name = action.payload.name;
    },
  },
  extraReducers: {},
});
export const { setTitle } = todoSlice.actions;

export default todoSlice.reducer;

// 사용할 컴포넌트
export function Counter() {
  const dispatch = useDispatch();
  return (
    <button onClick={() => dispatch(setTitle({ name: 'hi' }, content: 'con' }))}>
      setTitle
    </button>
  );
}

# reselect

createSelector로 실행할 수 있습니다. vue에서 vuex의 getter와 동일한 기능이라고 보시면 되겠습니다.

# reselect의 이점

  • redux store 값을 가져와 계산을 해서, redux가 적은 양의 필요한 데이터만 가지고 있게 도와줍니다
  • 구조가 바뀌어도 연관된 컴포넌트 바꿀필요없이 selector만 바꾸면 됩니다.
  • 메모되어 재계산 방지 효율적

# reselect를 안쓰면?

  1. state의 값을 useSelector를 이용해 컴포넌트로 이동하여, 컴포넌트에서 값을 핸들링할 수 있으나, 컴포넌트가 리렌더링 될때마다 함수가 재실행되는 낭비
  2. store 내부에서 함수를 이용하여 값을 바꾸는 방법
// reducer.js
export const getCompletedTodos = state =>
  state.todo.todos.filter.map(todo => todo.isCompleted);

그러나 이방법도 문제가 있다. store가 업데이트 될 때마다 getCompleteTodos는 매 번 계산을 하게된다. 그래서 createSelector를 이용해 값을 먼저 계산하고, 나온 값을 컴포넌트로 옮기는 방법을 사용한다. 위에서 말했듯 memoization을 이용한다. 즉, 이전에 계산된 값을 캐시에 저장하여 불필요한 계산을 없앤다.

# reselect 예시

selector로서 인자로 받는 state에서 우리가 필요한 부분을 가져오는 역할을 한다. 그 다음 인자인 함수에서는 inputSelectors에서 반환된 값을 인자로 받아 계산을 수행한다.

const listState = (state: RootState) => state.todoSlice.lists;

export const getFilterLike = createSelector(listState, lists => {
  return lists.filter(({ likes }: { likes: number }) => likes > 10);
});

reselect는 memoization이 적용되는데, 그 기준이 되는 값은 inputSelector의 결과값이다. 이 값이 바뀌지 않고 store가 업데이트 되었을 때, reselect는 저장된 cache 값을 사용하여 불필요한 재계산을 하지 않도록 해준다.

# immer의 produce

redux의 경우 객체 불변성(immutable)을 지켜야 합니다. 이 말이 무엇을 뜻하는지 모르시는 분은 불변성을 지켜야하는 이유 (opens new window)를 꼭 참조하기시 바랍니다. immer에 관한 내용은 immer 정리 (opens new window)

# redux-thunk

redux-thunk 기능을 공식적으로 지원합니다. redux-toolkit에서는 createAsyncThunk를 이용하여 thunk 처럼 사용합니다. 맨 아래 총 예시를 보시고 모르시는 부분은 공식사이트 (opens new window)를 참조해주세요.

# FSA 강제화

redux-toolkit에서는 FSA 방식을 사용하지 않으면 무조건 에러를 띄웁니다. 즉, action.payload를 통해 접근해야만 합니다.

export interface Action<Payload> extends AnyAction {
  type: string;
  payload: Payload;
  error?: boolean;
  meta?: Meta;
}

# Type Definition

redux의 reducer의 RootState에 대한 타이브 action 함수, payload에 대한 타입을 신경써야 하는 번거로움이 있었습니다. typescript에서 redux-saga 사용하기 (opens new window) 여기만 봐도 state, action에 대한 타입정의가 굉장히 번거로움이 있다는 것을 알 수 있습니다. 결론은 redux-toolkit에서 이 부분을 해결하여, 내장 타입으로 지원하기에 편리하게 코딩할 수 있습니다.

# 종합 예제

// store/index.ts
import {
  configureStore,
  ThunkAction,
  Action,
  getDefaultMiddleware
} from "@reduxjs/toolkit";
import logger from "redux-logger";
import todoSlice from "./example/exampleSlice";

export const store = configureStore({
  reducer: {
    todoSlice: todoSlice
  },
  middleware: getDefaultMiddleware().concat(logger),
  devTools: process.env.NODE_ENV !== "production"
});

export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;
// store/example/exampleSlice
import {
  createAsyncThunk,
  createSlice,
  PayloadAction,
  createSelector
} from "@reduxjs/toolkit";
import { getSplashImage } from "api";
import { RootState } from "../index";

const name = "todo";

export const fetchTodo = createAsyncThunk(
  `${name}/fetchTodo`, // 액션 이름을 정의해 주도록 합니다.
  async ({ test1, test2 }: { test1: number; test2: number }, thunkAPI) => {
    try {
      return (await getSplashImage(1)).data;
    } catch (e) {
      return thunkAPI.rejectWithValue(await e.response.data);
    }
  }
);

type stateType = {
  title: { name: string; content: number };
  content: string;
  loading: boolean;
  lists: any;
};

const initialState: stateType = {
  title: { name: "ttttt", content: 0 },
  content: "",
  loading: false,
  lists: []
};

export const todoSlice = createSlice({
  name,
  initialState,
  reducers: {
    setTitle: (
      state,
      action: PayloadAction<{ name: string; content: number }>
    ) => {
      state.title.name = action.payload.name;
    }
  },
  extraReducers: {
    [fetchTodo.pending.type]: (state, action) => {
      // 호출 전
      state.loading = true;
    },
    [fetchTodo.fulfilled.type]: (state, action) => {
      // 성공
      state.loading = true;
      state.lists = action.payload;
    },
    [fetchTodo.rejected.type]: (
      state,
      action: PayloadAction<{ message: string; status: number }>
    ) => {
      // 실패
      state.loading = true;
      state.title.name = action.payload.message;
      state.lists = [];
    }
  }
});

const listState = (state: RootState) => state.todoSlice.lists;

export const getFilterLike = createSelector(listState, lists => {
  return lists.filter(({ likes }: { likes: number }) => likes > 10);
});
export const { setTitle } = todoSlice.actions;

export const lists = (state: RootState) => state.todoSlice.lists;
export const titles = (state: RootState) => state.todoSlice.title;

export default todoSlice.reducer;
// 함수 실행할 컴포넌트 - components/Example.tsx
import React, { useState, useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
  lists,
  titles,
  getFilterLike,
  setTitle
} from "store/example/exampleSlice";

import { fetchTodo } from "store/example/exampleSlice";
import styles from "./Counter.module.css";

export function Counter() {
  const list = useSelector(lists);
  const title = useSelector(titles);
  const filterLikes = useSelector(getFilterLike);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(fetchTodo({ test1: 321, test2: 123 }));
  }, [dispatch]);

  return (
    <div>
      <p>{title.name}</p>
      {filterLikes.map(({ id }: { id: string }, index: React.ReactNode) => (
        <p key={id}>
          {id}
          {index}
        </p>
      ))}
      <button onClick={() => dispatch(setTitle({ name: "z", content: 2 }))}>
        setTitle
      </button>
    </div>
  );
}

# useSelector 안티패턴

  • 개발하던 도중 useSelector를 잘못 이용하면 리렌더링을 무지하게 많이 한다는 것을 보었습니다. useSelector는 react-redux에서 가져오는 것이니 redux-toolkit을 안쓰셔도 redux를 쓴다면 알아두시면 좋을 것 같아 추가로 작성합니다.

예를 들어 아래와 같은 todoSlice에 initalState가 있습니다.

type stateType = {
  title: { name: string, content: number }
  todoList: {name: string, content: number}[]
};

const initialState: stateType = {
  title: { name: "ttttt", content: 0 },
  todoList: []
};

그리고 이 값들을 사용하기 위해 컴포넌트에서 아래와 같이 사용합니다.

const { title, todoList } = useSelector((state: RootState) => state.todo);

여기에는 심각한 문제가 있습니다. title이 바뀌면 title을 사용하는 컴포넌트는 당연히 리렌더링 되겠지만 값이 바뀌지 않은 todoList를 바라보는 컴포넌트도 리렌더링됩니다.

이것을 막기 위해 아래와 같이 useSelector를 바꿀 수 있습니다.

const title = useSelector((state: RootState) => state.todo.title);
const todoList = useSelector((state: RootState) => state.todo.todoList);

이렇게 사용하면 title이 바뀌여도 todoList를 바라보는 컴포넌트는 리렌더링 되지 않습니다. 그런데 slice에 엄청 많은 state들을 가져올텐데 그걸 한줄한줄 다 쓴다는 것은 말도 안되죠. 그래서 react-redux에서 제공해주는 shallowEqual를 사용합니다. 간단히 말해 이전값과 바뀐 값을 비교하여 같으면 리렌더링 안하고 다르면 리렌더링 합니다. 하지만 shallowEqual이기 때문에 객체의 가장 밖에 있는 값들을 모두 비교해줍니다.

아래 예시를 통해 shallowEqual에 대해 설명 하겠습니다.

const obj = {
  nkh: {
    age: 3,
    name: 2,
    city: 1
  },
  joshua: 1,
  kally: [{ id: 1 }]
};

위처럼 값이 있을때 가장 바깥의 객체는 obj.nkh, obj.joshua, obj.kally입니다. shallowEqual는 obj.nkh, obj.joshua, obj.kally이 값이 바뀌었냐만 비교하고 object.kally[0]의 값이 바뀐 것은 비교하지 않습니다.

그래서 이 shallowEqual를 이용하면 한줄한줄 쓰는 코드를 아래와 같이 개선 할 수 있습니다.

const { title, todoList } = useSelector(
  (state: RootState) => ({
    title: state.education.title,
    todoList: state.education.todoList
  }),
  shallowEqual
);

둘중 하나의 방법으로 useSelector를 사용하면 불필요하게 리렌더링 하는 일을 막을 수 있습니다.

# hooks로 useSelector 개선

  • 위처럼 shallowEqual를 사용하려면 매번 RootState와 shallowEqual를 import 해줘야합니다. hooks로 만들어서 사용하면 쉽게 사용할 수 있을 것 같습니다.

# useSelectorTyped.ts

// useSelectorTyped.ts
import { useSelector, shallowEqual } from "react-redux";
import { RootState } from "app/store";

export default function useSelectorTyped<T>(fn: (state: RootState) => T): T {
  return useSelector(fn, shallowEqual);
}

# 사용하는 컴포넌트

import useSelectorTyped from "features/useSelectorTyped";

const Index = () => {
  const { title, todoList } = useSelectorTyped(state => ({
    title: state.education.title,
    todoList: state.education.todoList
  }));
};

# 정리

저는 지금 진행하는 프로젝트에 saga에 의해 액션 타입, 액션이 너무 많이 늘어나고, 추가 개발을 진행할 때도 디버깅은 편하지만, 공수가 많이 들어 redux-toolkit을 사용하는 것을 건의할 예정입니다. 한번 정리해보니 저것을 쓰면 많은 패키지도 삭제되고, 코드양도 많이 줄어드는 이점이 보이기 때문입니다. 위 예제로 이해가 되지 않으신 분들은 cra-template-redux-typescript (opens new window) 여기에 들어가셔서 프로젝트 코드를 분석해보시기 바랍니다.

# 참조

#react #redux #redux-toolkit
노경환
이 글이 도움이 되셨다면! 깃헙 스타 부탁드립니다 😊😄
최근변경일: 3/25/2024, 12:16:11 PM