[React] 지역 상태를 매끄럽게 관리하기: Context + useReducer + RTK

Gyuwon Lee·2023년 4월 12일
0
post-thumbnail

Context API를 도입하기까지의 시행착오에서 이어지는 글입니다.

서론: 우리는 언제 Context API를 사용하게 될까?

로그인한 유저 정보나, 뷰포트 값 등 어플리케이션 전체적으로 필요한 정보는 보통 redux, recoil 등을 사용해 전역 상태로 관리하게 된다. 하지만 form 이나 filter, modal 처럼 어플리케이션의 일부분 안에서만 공유되는 값은 경우가 다르다.

‘일부’ 컴포넌트들만 이 값에 접근하게 되므로 전역 상태로 두기에는 적절하지 않다. 또한 바로 위에서 예시로 든 form, filter, modal 같은 컴포넌트는 다른 서비스에서 가져다 사용하기 좋은 컴포넌트들이다.

redux 등 decoupling 하기 어려운 라이브러리를 적용했다간 해당 redux store 바깥 또는 아예 다른 서비스에서 이 컴포넌트를 가져다 사용하고 싶어도 사용하기 어려워질 것이다. 즉 컴포넌트의 재사용성을 뚝 떨어뜨리는 결과를 낳게 된다.

시작: useReducer

이런 경우 가장 먼저 개선해볼 수 있는 부분은 부모에서 useState 대신 useReducer를 사용하는 것이다.

const [state, dispatch] = useReducer(reducer, initialState);

이렇게 reducer 함수를 바탕으로 상태가 변경되도록 하면 우선 하나의 상태에 여러 컴포넌트가 접근해 변경시킨다고 해도 reducer 함수를 통해 상태를 예측 가능하다는 장점이 생긴다.

만약 상태가 객체 혹은 배열 타입인 경우에는, 각 컴포넌트 별로 상태의 각기 다른 부분을 변경하고자 할 때 매번 로직을 작성해줄 필요가 없어진다. reducer 에 type 별로 action 처리 함수를 정의해두면 끝이다. 따라서 UI 로직으로부터 상태 변경 로직을 효과적으로 분리할 수 있다.

그러면 우리는 이제 아래와 같은 방식으로 상태를 변경하게 될 것이다.

<Checkbox
	onChange={dispatch({type: 'CHANGE_NAME', name})}
/>

하지만 useReducer를 사용해 상태 변경 로직을 분리하였다 해도, 깊은 곳에 위치한 자식 컴포넌트에게는 여전히 부모의 state를 prop으로 drilling 해주어야 하는 문제가 남아 있다. 그래서 지난 글에서 Context API를 도입하게 되었다.

export const ModalProvider: React.FC = ({ children }) => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  return (
    <ModalStateContext.Provider value={state}>
      <ModalDispatchContext.Provider value={dispatch}>
        {children}
      </ModalDispatchContext.Provider>
    </ModalStateContext.Provider>
  );
};

export function useModalState() {
  const state = React.useContext(ModalStateContext);

  if (!state) {
    throw new Error('Cannot find ModalStateContext');
  }

  return state;
}

export function useModalDispatch() {
  const dispatch = React.useContext(ModalDispatchContext);
  if (!dispatch) {
    throw new Error('Cannot find ModalDispatchContext');
  }

  return dispatch;
}

useReducer의 결과인 state와 dispatch 함수를 각각 다른 provider로 분리하여 재렌더링을 (그나마) 줄이고자 했고, reducer 와 initialState 값을 컴포넌트 외부에서 관리할 수 있게 되었다.

이제 이 reducer 와 initialState 를 어디에서 관리할 것인지에 대한 논의로 넘어가야 한다.

아니, 정확히 말하면 reducer 와 action 을 둘 곳을 찾아야 한다. 보통 reducer 를 관리하는 위치에서 initialState 도 관리되고 있었기 때문이다.

현재 작업 중인 서비스에서는

  1. context.tsx 안에 바로 initialState 및 reducer 함수를 정의
  2. /ducks 디렉토리 아래에 reducers, actions, types로 각각 파일을 분리해 관리

하는 두 가지 케이스가 있었는데, 앞선 글에서 이야기했듯 나는 Contex가 값을 전달하는 pipe의 기능을 한다고 생각했으므로 상태 생성 및 변경과 관련된 reducer 및 action들을 별도로 관리할 수 있는 case 2를 따르기로 했다.

아래 두 코드는 위에서 비교한 case 1과 2의 구현 코드다.

// case 1
type State = {
	todoID: number;
	todoContent: string;
}[];

type Action =
	| {
	    type: 'SET_TODO';
	    todoID: number;
			todoContent: string;
	  }
	| {
	    type: 'DELETE_TODO';
	    todoID: number;
	  } ...

const todoReducer = (state: State, action: Action) => {
	switch (action.type) {
    case 'SET_TODO':
      return [
        ...state,
        // update new TODO
      ];
		...
}

case 2의 경우에는 RTK의 createAction, createReducer 함수를 사용해 구현했다.

// case 2

// action.ts
import { createAction } from '@reduxjs/toolkit';

const setTodo = createAction('SET_TODO');
const deleteTodo = createAction('DELETE_TODO');
...

// reducers.ts
import { createReducer } from '@reduxjs/toolkit'
import * as actions from './actions';
import type { State } from './types';

export const initialState: State = [];

export default createReducer<State>(initialState, builder =>
  builder
    .addCase(actions.setTodo, state => {
      return [
        ...state,
        // update new TODO
      ];
    })
...

코드가 눈에 띄게 줄어들지는 않았지만, 우선 별도 파일로 분리했다는 점과, RTK 함수를 가져다 사용하면서 불필요한 보일러플레이트를 일부 생략할 수 있게 되었고 내장된 immer 라이브러리의 도움을 받을 수 있게 되었다.

탐색: createAction과 createReducer가 정확히 어떤 함수지?

나는 redux 대신 바로 RTK를 사용했기 때문에, createSlice만 알 뿐 위의 두 함수는 정확히 알지 못했다. “Context + RTK 조합은 어떨까요?” 라는 제안과 함께 사라지신 팀장님을 뒤로 하고, 나는 이 두 함수가 어떤 역할인지 정확히 알지 못한 채로 코드를 뒤적거리기 시작했다.

import { createAction } from "@reduxjs/toolkit";

const setTodo = createAction("SET_TODO");
const deleteTodo = createAction("DELETE_TODO");

console.log(setTodo, deleteTodo);
// 단지 변수 명으로 사용시 -> function return (action creator)

console.log(setTodo.type, deleteTodo.type);
/* Type을 붙여서 사용시 -> Type return
SET_TODO, DELETE_TODO */

console.log(getUsersStart(), getUsersSuccess(), getUsersFail());
/* 함수 호출로 사용시 -> action return (액션의 경우 어쨌거나, payload와 함깨 보내짐)
{ type: 'SET_TODO', payload: undefined } 
{ type: 'DELETE_TODO', payload: undefined } */

정리하자면 createAction는 위처럼 생긴 함수다.

단순히 createAction을 한 번 호출하게 되면 액션 생성 함수가 리턴된다. 액션이 아니라 액션 ‘생성 함수’다 (이름은 createAction, 즉 ‘액션 생성’ 이지만). 그러니 dispatch 안에서 createAction을 한 번 호출하면 에러를 뱉을 것이다.

// dispatch 안에는 액션 함수가 아닌 액션 객체가 들어가야 한다.
<Checkbox
  onChange={dispatch(createAction('SET_TODO', todo => ({ payload: todo })))}
/>;

공식문서에 따르면, createAction을 타입스크립트에서 사용할 때는 내가 리턴하고자 하는 payload 의 타입을 타입 인수로 전달해줄 수 있다. (공식문서) (참고)

export declare function createAction<
	PA extends PrepareAction<any>, T extends string = string
>
	(type: T, prepareAction: PA): 
		PayloadActionCreator<ReturnType<PA>['payload'], T, PA>

위 정의에서 ReturnType<PA>['payload']prepareAction 함수에 의해 리턴되는 객체(=액션 객체) 중 payload 속성의 타입임을 알 수 있다.

아무튼, 이 createAction으로 만들어진 액션 생성 함수는 아래와 같은 타입을 갖는다.

// ActionCreatorWithoutPayload<string>
const triggerToggle = createAction<void>('TRIGGER_TOGGLE')

// ActionCreatorWithPayload<payloadType, string>
const setValue = createAction<payloadType>('SET_VALUE')

얘네들을 createReducer 에 넣어줄 때에는 타입 정의를 생략할 수 있다.

import { createAction, createReducer } from '@reduxjs/toolkit'

const increment = createAction<number>('counter/increment')
const decrement = createAction<number>('counter/decrement')

const counterReducer = createReducer(0, (builder) => {
  builder.addCase(increment, (state, action) => state + action.payload)
  builder.addCase(decrement, (state, action) => state - action.payload)
})

builder.addCase가 ActionCreator 타입을 받도록 정의되어 있기 때문이다.

addCase<ActionCreator extends TypedActionCreator<string>>
	(actionCreator: ActionCreator, reducer: CaseReducer<State, ReturnType<ActionCreator>>)
		: ActionReducerMapBuilder<State>

위에서 보았듯 createAction은 ActionCreator 타입의 액션 생성 함수를 리턴하므로, 별도의 타입 정의 없이 이 함수를 reducer 안에 추가하는 것이 가능하다.

해결: 나는 createSlice 쓸건데?

여기까지 기존 코드를 참고해가며 공부한 후, 나는 action과 reducer를 한 번에 생성할 수 있는 createSlice를 사용하기로 했다. 기존 코드 중 createSlice가 사용된 부분을 찾을 수는 없었지만, 단지 useReducer에 필요한 reducer와 action을 만드는 것 뿐이라면 createSlice를 사용하지 않을 이유가 없었기 때문이다.

import { createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
	name: 'counter',
	initialState,
	reducers: {
		increment: (state, action: number) => return state += action.payload
		decrement: (state, action: number) => return state -= action.payload
	},
})

export const reducer = slice.reducer;
export const actions = slice.actions;

이렇게 함으로써 코드의 양도 훨씬 줄이고, action이나 reducer의 타입(형태) 변경에 대응하기 용이해졌다. createAction과 createReducer로 나누어 사용하는 경우에는

  • 코드가 길어지거나 파일이 두 개로 나뉘어 패턴 전체를 파악하기 위한 가독성이 떨어진다.
  • 액션 타입 값과 reducer의 switch문 안의 값이 일치할 수 있도록 별도의 constant로 관리해야 한다.

등등의 불편함이 있었는데, 이러한 점들을 개선할 수 있게 되었다.

난관: 액션 객체들의 타입을 모르겠다

그런데 한 번에 받아들인 내용이 쌓이면서 나는 슬슬 초심자의 행운이 아닌 ‘초심자의 불안’을 느끼기 시작했다. 와르르 공부한 내용이 머릿속에서 정리되는 대신 섞이기 시작하는 느낌을 받았기 때문이다.

useReducer에 들어갈 reducer 함수는 만들었고, redux였다면 이제 useDispatch를 쓰면 되었겠지만… useReducer의 dispatch 함수에는 어떻게 타입을 전달할 수 있을까?

useReducer 함수는 [ReducerState<R>, Dispatch<ReducerAction<R>>] 타입의 배열을 리턴한다.

function useReducer<R extends Reducer<any, any>>(
    reducer: R,
    initialState: ReducerState<R>,
    initializer?: undefined
): [ReducerState<R>, Dispatch<ReducerAction<R>>];

따라서 보통은 React.Dispatch 타입에다, "값으로 들어올 수 있는 액션 객체들의 유니온 타입"을 타입 인수로 넣어 사용하는 듯하다.

type Dispatch = React.Dispatch<ActionTypes>

이 ActionTypes를 정의하는 데 처음에는 잠깐 뇌정지가 왔다. slice.actions에 커서를 갖다대면 CaseReducerActions 라는 타입이 등장하는데, 이 값을 그대로 사용하면 에러가 발생했다.

const actions = slice.actions;

type Action = typeof actions;

/**
* type Actions = {
*  setFilterAttributes: ActionCreatorWithPayload<any, string>;
*  setFilterAttributesInput: ActionCreatorWithPayload<any, string>;
*  resetFilterAttributesInput: ActionCreatorWithoutPayload<...>;
*  setPaidFilterCheckedState: ActionCreatorWithPayload<...>;
*  setActiveFilterCheckedState: ActionCreatorWithPayload<...>;
*	}
*/
type Dispatch = React.Dispatch<Action> // Type 'Dispatch<AnyAction>' is not assignable to type 'Dispatch'.

그렇다. Dispatch 타입은 아래와 같이 액션의 타입을 받도록 짜여 있는데, 위에서 정의한 타입 Actions에는 액션 생성 함수들의 타입이 객체 형태로 정의되어 있다.

type Dispatch<A> = (value: A) => void;

이제 문제가 정의되었다.

  • 나는 액션 객체들의 타입이 필요하다.
  • 현재 Actions는 액션 생성 함수들의 타입을 리턴하고 있다.
  • 액션 객체들의 타입은 액션 생성 함수의 리턴 타입으로부터 알 수 있다.

위의 slice 예시를 가져와 여기서 어떻게 액션 객체들의 타입을 알아낼 수 있을지 한번 적용해보자.

import { actions } from './slice';

type Action = typeof actions;
type ActionKeys = keyof Action; // 액션 생성 함수명들 (union)
type ActionKeysTypes = Action[ActionKeys] // 액션 생성 함수들의 실제 타입
type ActionTypes = ReturnType<ActionKeysTypes>
  • type Action: actions의 type. CaseReducerActions<{…}> 가 연산된 형태
  • type ActionKeys: 객체 타입인 Action으로부터 key만 문자열 형태로 추출한 union type.
  • type ActionKeysTypes: Action 객체의 각 value 들을 추출한 indexed access type
    • 대괄호 안의 ActionKeys 가 유니온 타입이므로 ActionKeysTypes 역시 유니온 타입이 된다.
  • type ActionTypes: 각 value(=액션 생성 함수)의 리턴 타입(=액션 객체)

따라서 아래와 같이 간단히 dispatch의 타입을 정의해줄 수 있었다:

type Actions = typeof actions;
type Action = ReturnType<Actions[keyof Actions]>;
type Dispatch = React.Dispatch<Action>;

변수 slice.action에 커서를 올리면 CaseReducerActions<{…}> 라는 타입으로 표현되지만, 공식문서에 따르면 현재 createSlice가 리턴하는 actions 객체는 actions : Record<string, ActionCreator> 로 객체 타입이다. (참고: 공식문서)

import type { ActionType } from 'typesafe-actions';
import type { actions } from './slice';

export type TrackAction = ActionType<typeof actions>;

뒤늦게 다른 코드를 찾아보니 typesafe-actions 라이브러리를 사용해 간단하게 타입을 추론해내고 있었다.

ActionType
Powerful type-helper that will infer union type from **import as ... or action-creator map* object.

앞서 말했듯 slice.action 역시 Record, 즉 객체 타입이기 때문에 ActionType을 통해 추론될 수 있는 듯하다.

마무리: state, reducer 전달 및 dispatch 타입 정의

import React from 'react';

import { initialState, reducer } from './slice';

import type { actions } from './slice';
import type { State } from './types';

type Actions = typeof actions;
type Action = ReturnType<Actions[keyof Actions]>;
type Dispatch = React.Dispatch<Action>;

const StateContext = React.createContext<State | null>(null);
const DispatchContext = React.createContext<Dispatch | null>(null);

export const Provider: React.FC = ({ children }) => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
};

export function useStateContext() {
  const state = React.useContext(StateContext);

  if (!state) {
    throw new Error('Cannot find StateContext');
  }

  return state;
}

export function useDispatchContext() {
  const dispatch = React.useContext(DispatchContext);
  if (!dispatch) {
    throw new Error('Cannot find DispatchContext');
  }

  return dispatch;
}

최종적으로 위와 같이 createSlice로부터 얻어낸 reducer와 action을 사용해 context를 만들어 값을 관리할 수 있게 되었다.

배운 점 🙌

  • 전역 상태를 적절히 사용하자

    • 앞선 글에서 이야기했듯 나는 최대한 부모 컴포넌트가 local state를 들고 있는 방식이 리액트다운 데이터 흐름이라고 생각했지만, 전역 상태를 적절히 사용해야 코드의 가독성이나 로직의 간결성 모두 향상시킬 수 있음을 배웠다.
  • Context API는 Redux의 열화판이 아니라, 여전히 강력한 선택지

    • Redux나 Recoil 등 전역 상태관리 도구를 사용하면 Context API는 전혀 쓸 일이 없는 것인 줄 알았는데, 큰 규모의 어플리케이션 개발에 참여하다 보니 어플리케이션의 일부분 안에서 공유되어야 하는 값들이 생겨났다.
    • 이러한 값은 당연히 ‘전역’ 상태에 해당하지는 않으니 Redux나 Recoil 값으로 두기에는 적절하지 않고, 또 해당 기능을 다른 서비스에 이식하는 경우를 생각해보면 decoupling이 용이해야 할 것이다.
    • 이러한 상황에서 Context API는 어플리케이션의 일부만 감싸 지역적으로 공유되는 값을 전달하기에 탁월하다. Context는 각각의 페이지, form, filter 등등이 독립적으로 가질 수 있다.
  • Redux-toolkit은 store가 전부가 아니다!

    • 보통 RTK는 상태관리를 위해 store를 생성하는 용도로 많이 사용하지만, 액션 및 리듀서 함수를 생성하는 용도로 일부 함수만 가져다 사용해도 강력한 효과를 낼 수 있음을 배웠다.
    • Context와 함께 사용하면 전역 저장소에 불필요한 값을 넣지 않으면서도 Provider 범위 안에서의 상태관리 로직을 깔끔하게 유지하고 불변성을 효과적으로 지킬 수 있음을 체감했다!

참고:

RTK for the useReducer Hook: A Guide | HackerNoon

React's useReducer with Redux Toolkit. Why not?

profile
하루가 모여 역사가 된다

0개의 댓글