Context API를 도입하기까지의 시행착오에서 이어지는 글입니다.
로그인한 유저 정보나, 뷰포트 값 등 어플리케이션 전체적으로 필요한 정보는 보통 redux, recoil 등을 사용해 전역 상태로 관리하게 된다. 하지만 form 이나 filter, modal 처럼 어플리케이션의 일부분 안에서만 공유되는 값은 경우가 다르다.
‘일부’ 컴포넌트들만 이 값에 접근하게 되므로 전역 상태로 두기에는 적절하지 않다. 또한 바로 위에서 예시로 든 form, filter, modal 같은 컴포넌트는 다른 서비스에서 가져다 사용하기 좋은 컴포넌트들이다.
redux 등 decoupling 하기 어려운 라이브러리를 적용했다간 해당 redux store 바깥 또는 아예 다른 서비스에서 이 컴포넌트를 가져다 사용하고 싶어도 사용하기 어려워질 것이다. 즉 컴포넌트의 재사용성을 뚝 떨어뜨리는 결과를 낳게 된다.
이런 경우 가장 먼저 개선해볼 수 있는 부분은 부모에서 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 도 관리되고 있었기 때문이다.
현재 작업 중인 서비스에서는
하는 두 가지 케이스가 있었는데, 앞선 글에서 이야기했듯 나는 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 라이브러리의 도움을 받을 수 있게 되었다.
나는 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 안에 추가하는 것이 가능하다.
여기까지 기존 코드를 참고해가며 공부한 후, 나는 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로 나누어 사용하는 경우에는
등등의 불편함이 있었는데, 이러한 점들을 개선할 수 있게 되었다.
그런데 한 번에 받아들인 내용이 쌓이면서 나는 슬슬 초심자의 행운이 아닌 ‘초심자의 불안’을 느끼기 시작했다. 와르르 공부한 내용이 머릿속에서 정리되는 대신 섞이기 시작하는 느낌을 받았기 때문이다.
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;
이제 문제가 정의되었다.
위의 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 typetype 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을 통해 추론될 수 있는 듯하다.
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를 만들어 값을 관리할 수 있게 되었다.
전역 상태를 적절히 사용하자
Context API는 Redux의 열화판이 아니라, 여전히 강력한 선택지
Redux-toolkit은 store가 전부가 아니다!
참고: