Redux Toolkit 튜토리얼 (2)

redux
redux

configureStore

// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
 
export default configureStore({
  reducer: {
    counter: counterReducer,
  },
});

스토어는 configureStore를 이용하여 생성합니다. configureStore는 리듀서를 전달하고, 리듀서에는 키와 값이 들어가는데 키는 state.counter 섹션을 의미하며 값은 디스패치될 때마다 담당하는 리듀서 함수를 의미합니다.

예를 들어 블로그 앱의 경우 다음과 같이 정의할 수 있습니다.

import { configureStore } from '@reduxjs/toolkit';
import usersReducer from '../features/users/usersSlice';
import postsReducer from '../features/posts/postsSlice';
import commentsReducer from '../features/comments/commentsSlice';
 
export default configureStore({
  reducer: {
    users: usersReducer,
    posts: postsReducer,
    comments: commentsReducer,
  },
});

또는 combineReducers를 이용하여 하나의 rootReducer를 만들어 전달할 수도 있습니다.

const rootReducer = combineReducers({
  users: usersReducer,
  posts: postsReducer,
  comments: commentsReducer,
});
 
const store = configureStore({
  reducer: rootReducer,
});

Redux Slices

// features/counter/counterSlice.ts
import { createSlice } from '@reduxjs/toolkit';
 
export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
});
 
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
 
export default counterSlice.reducer;

위 리듀서 로직은 3개의 액션 타입이 존재합니다.

  • {type: "counter/increment"}
  • {type: "counter/decrement"}
  • {type: "counter/incrementByAmount"}

액션의 타입은 문자열입니다. createSlice을 사용하여 slice의 name을 정의하고 리듀서 함수를 작성하면 액션 생성 함수가 자동으로 생성됩니다. createSlice는 초기 상태 값을 전달해야 하므로 초기 상태가 정의되어 있어야 합니다.

Reducer 규칙

  • 상태와 액션을 기반으로 새 상태 값만 계산해야 합니다.
  • 불변성을 유지해야 하며, 기존 상태 값을 변경할 수 없습니다.
  • 비동기 로직을 수행할 수 없습니다.

리덕스의 목표 중 하나는 예측 가능한 코드를 만드는 것입니다. 함수가 순수하지 않으면 예측하기 어려워집니다.

리듀서와 불변성

리듀서는 상태 값을 변경할 수 없습니다.

// ❌
state.value = 123;
// ✅
return {
  ...state,
  value: 123,
};

리덕스에서 상태를 변경해서는 안 되는 이유는 다음과 같습니다.

  • UI 최신 값 업데이트 버그가 발생되기 쉽습니다.
  • 테스트 코드를 작성하기가 더 어려워집니다.
  • dev tools을 이용하여 Time Travel Debugging(시간 여행 디버깅)을 사용할 수 없습니다.

그렇기 때문에 이전 리덕스에서는 스프레드 연산자나 메서드를 통해 상태를 복사하여 불변성을 유지하였는데 이렇게 되면 코드가 복잡해지는 단점이 존재합니다.

// before
function handwrittenReducer(state, action) {
  return {
    ...state,
    first: {
      ...state.first,
      second: {
        ...state.first.second,
        [action.someId]: {
          ...state.first.second[action.someId],
          fourth: action.someValue,
        },
      },
    },
  };
}
 
// after
function reducerWithImmer(state, action) {
  state.first.second[action.someId].fourth = action.someValue;
}

redux toolkit의 createSlice는 내부에 immer 라이브러리를 사용하여 간편하게 불변성을 지킬 수 있습니다.

// features/counter/counterSlice.ts
export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
});

incrementdecrement 액션은 항상 1을 증감하기 때문에 action 매개변수가 필요 없지만, incrementByAmountpayload를 통해 값을 전달받아 추가하는 로직이므로 action 매개변수가 추가되었습니다.

useSelector

useSelector 훅을 사용하면 스토어에서 상태를 추출할 수 있습니다. 액션이 디스패치되고 스토어가 업데이트될 때마다 useSelector는 함수를 다시 실행하여 값을 비교합니다. 값이 다르면 컴포넌트는 새 값으로 다시 렌더링 됩니다.

// 'state' is of type 'unknown'.ts(18046)
const count = useSelector((state) => state.counter.value);

타입스크립트에서는 state.counter가 추론되지 않아 에러가 발생하기 때문에 추가 작업이 필요합니다.

// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
 
export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});
 
export type RootState = ReturnType<typeof store.getState>;
}

이렇게 하면 정상적으로 counter.value가 추론되어 타입 에러가 발생하지 않지만 매번 타입을 추가하기엔 번거롭기 때문에 커스텀 훅을 만들어 사용할 수 있습니다.

// features/Counter.tsx
export default function Counter() {
  const count = useSelector((state: RootState) => state.counter.value);
  ...
}
// hooks/index.ts;
 
import { TypedUseSelectorHook, useSelector } from 'react-redux';
import type { RootState } from 'app/store';
 
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// features/Counter.tsx
export default function Counter() {
  const count = useAppSelector((state) => state.counter.value);
  ...
}

위와 같이 useSelector 대신 useAppSelector를 사용하면 타입을 추가로 지정할 필요가 없습니다.

useDispatch

useDispatch는 dispatch 메서드를 제공하여 액션을 스토어에 전달할 수 있게 도와주는 훅입니다.

const dispatch = useDispatch();

다음과 같이 액션을 전달할 수 있습니다.

<button type="button" onClick={() => dispatch(increment())}>
  +
</button>

useDispatch는 타입스크립트 환경에서 타입 에러가 발생하지 않습니다. 하지만 Thunk를 사용하면 에러가 발생하는데 그 이유는 useDispatch는 thunk 미들웨어에 대해 알지 못하기 때문에 타입을 추가로 지정해 줘야 합니다.

// app/store.ts
export type AppDispatch = typeof store.dispatch;
// 사용 시
const dispatch: AppDispatch = useDispatch();

useAppSelector와 마찬가지로 훅을 만들어 편하게 사용할 수 있습니다.

// hooks/index.ts;
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from 'app/store';
 
type DispatchFunc = () => AppDispatch;
export const useAppDispatch: DispatchFunc = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Provider

useSelectoruseDispatch를 사용하여 스토어와 통신하려면 index.tsx에서 <App /> 컴포넌트를 Provider로 감싸 스토어를 전달해야 합니다.

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import reportWebVitals from './reportWebVitals';
import App from './App';
import { store } from './app/store';
 
const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement,
);
 
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
);

Thunk로 비동기 로직 작성하기

지금까지 예시 코드는 모두 동기로 작성된 코드인데요. 애플리케이션에서 ajax를 이용하여 api에서 데이터를 가져올 수 있기 때문에 비동기 로직을 넣을 장소가 필요합니다.
thunk는 비동기 로직을 포함할 수 있는 특정 종류의 리덕스 함수입니다. thunk는 두 가지 함수를 사용하여 작성됩니다.

  • dispatch와 getState를 인수로 받는 내부 thunk 함수
  • 외부 생성자 함수는 thunk 함수를 생성하고 반환합니다.
export const incrementAsync = (amount) => (dispatch) => {
  setTimeout(() => {
    dispatch(incrementByAmount(amount));
  }, 1000);
};

일반 액션처럼 dispatch(incrementAsync(5))를 호출하면 비동기 코드가 실행되고 다른 액션을 디스패치할 수 있습니다.

thunk를 사용하려면 스토어에 redux-thunk 미들웨어를 추가해야 합니다. 하지만 redux toolkit의 configureStore는 이미 초기 셋팅에 설정되어 있어 바로 사용할 수 있습니다. 서버에서 데이터를 가져오기 위한 예시 코드는 다음과 같습니다.

// features/counter/counterSlice.ts
 
const fetchUserById = (userId) => {
  return async (dispatch, getState) => {
    try {
      const user = await userAPI.fetchById(userId);
      dispatch(userLoaded(user));
    } catch (err) {}
  };
};

참조