Redux-Actions

리덕스 액션즈는 리덕스에서 귀찮은 일들을 상당부분 줄여주는 유틸리티 라이브러리이다. 많은 기능들이 있지만 필자가 가장 즐겨 쓰는 (사실은 그것만 사용하는)함수는 두개. createActionhandleActions이다.

이 두가지 함수는 리덕스에서 상당히 귀찮은 작업인 ActionCreator를 만드는 작업과 Reducer를 만드는 작업의 보일러플레이트를 굉장히 편하게 줄여준다. 그래서 개인적으로 작업을 하든, 업무를 할때든 리덕스를 사용할때면 거의 항상 사용하곤 했다.

아래는 라이브러리 사용을 간단하게 작성해 보았다. 하기의 사용 예 말고 다양한 사용 사례가 많지만 개인적으로는 대부분 이 사용 방법에서 벗어나지 않게 사용했던것 같다.

// 액션
const CHANGE_NAME = 'CHANGE_NAME';
const CHANGE_AGE = 'CHANGE_AGE';

const defaultStae = {
  name: 'john',
  age: 27
};

// ------ Before ----------

// 액션 크리에이터
const changeName = name => ({
  type: CHANGE_NAME,
  payload: { name }
});
const changeAge = age => ({
  type: CHANGE_NAME,
  payload: { age }
});

// 리듀셔
const user = (state = defaultState, action) => {
  switch (action.type) {
    case CHNAGE_NAME: {
      return {
        ...state,
        name: action.payload.name
      };
    }
    case CHANGE_AGE: {
      return {
        ...state,
        age: action.payload.age
      };
    }
    default:
      return state;
  }
};

// -------- after -------

// 액션 크리에이터
const changeName = createAction(CHANGE_NAME, (name = { name }));
const changeAge = createAction(CHANGE_AGE, (age = { age }));

// 리듀서
const user = handleActions({
  [CHANGE_NAME]: (state, { payload }) => ({
    ...state,
    name: payload.name
  }),
  [CHANGE_AGE]: (state, action) => {
    ...state,
    age: payload.age
  }
});

확실히 구문이 읽기 편하고 깔끔해졌다. 여기까지 봐서는 안쓸 이유가 없어보인다. 하지만 여기에는 문제가 있다. 이 라이브러리는 타입스크립트 지원이 굉장히 허술하게 되어있다.

무엇이 문제인가?

여러가지 문제가 있지만, 가장 큰 문제는 createAction이 뱉어내는 액션 크리에이터의 타입은 무조건 string으로 고정되어 있다는 사실이다. 타입 정의를 한번 보자.

// FSA-compliant action.
// See: https://github.com/acdlite/flux-standard-action
export interface BaseAction {
  type: string;
}

export interface Action<Payload> extends BaseAction {
  payload: Payload;
  error?: boolean;
}

이 라이브러리를 타입스크립트에서 사용하려고 시도해 보았던 사람들은 알 것이다. string 타입으로 고정해서 내놓는 것이 얼마나 끔찍한 일인지.. 우선 이 액션 크리에이터가 뱉어내는 액션은 기본적으로 string타입이기 때문에 액션 크리에이터가 리턴하는 인터페이스를 따로 짜주어야 한다. 이렇게 말이다.

const changeName = createAction(CHANGE_NAME, name => ({ name }));
interface ChangeName {
  type: typeof CHANGE_NAME;
  payload: {
    name: string;
  };
}

이전 글에서 소개했던 ReturnType을 이용하는 방법도 어림없다. 왜냐하면 changeName이 리턴하는 함수는 무조건 typestring이기 때문에 올바르게 액션을 만들어내지 못하기 때문이다.

또 다른 문제는 handleActions에도 있다. 이 함수의 문제 역시 꽤 많다. 우선 가장 기본이 되는 타입 정의부터 살펴보자.

export type Reducer<State, Payload> = (
  state: State,
  action: Action<Payload>
) => State;

export type ReducerMeta<State, Payload, Meta> = (
  state: State,
  action: ActionMeta<Payload, Meta>
) => State;

export type ReducerMapValue<State, Payload> =
  | Reducer<State, Payload>
  | ReducerNextThrow<State, Payload>
  | ReducerMap<State, Payload>;

export interface ReducerMap<State, Payload> {
  [actionType: string]: ReducerMapValue<State, Payload>;
}

export function handleActions<State, Payload>(
  reducerMap: ReducerMap<State, Payload>,
  initialState: State,
  options?: Options
): ReduxCompatibleReducer<State, Payload>;

여기서도 보이는 문제는 액션이 typestring으로 간주한다는 점이다. 액션 타입에 대하여 제네릭이든지 어떤 타입 추론에 도움이 될만한 정보가 존재하지 않는다. 이 끔찍한 가정은 handleActions에서 두번째 제네릭의 인자로 “액션”이 아닌 “페이로드”만 받는것과도 상통한다. 그러면 이 함수를 어떻게 써야할까?

// 페이로드를 가져오기 위한 헬퍼 타입
type GetPayload<A extends (...args: any[]) => any> = ReturnType<A> extends Action<infer P>
  ? P
  : never;

const changeName = createAction(CHANGE_NAME, (name: string) => ({ name }));
const changeAge = createAction(CHANGE_AGE, (age: number) => ({ age }));

type UserPayloads = GetPayload<typeof changeAge | typeof changeName>;

interface User {
  age: number;
  name: string;
}

const defaultState = {
  age: 27,
  name: 'john'
};

const user = handleActions<User, UserPayloads>(
  {
    [CHANGE_NAME]: (state, action) => {
      // 으윽.. 머리가...!
      if(!action.payload.name) {
        return state;
      }
      return {
        ...state,
        name:
      };
    }
  },
  defaultState
);

무언가 머리가 아픈 부분이 발견되었는가? CHANGE_NAME이 받는 함수의 actionTypescriptswitch/case문 안에서의 Type narrowing의 효과를 전혀 받지 못한다. 모든 액션의 페이로드가 섞여있기 때문에 각 액션 타입에 맞는 페이로드를 걸러낸 다음 사용해야 한다. 이렇게 되면 사실상 Typescript를 사용하는 이점이 많이 사라지기 때문에 타입스크립트를 사용하면서 이 라이브러리를 사용하기 않게 되었다.

대안?

물론 대안을 찾아보지 않은 것은 아니다. 문제를 해결하기 위한 유명한 라이브러리가 있는데 typesafe-actions라는 라이브러리이다. 정말 좋은 라이브러리이지만 이 라이브러리의 사용법이 익숙하지 않고, redux-acton의 액션-핸들액션 맵핑 방식이 가독성이 매우 뛰어나다고 생각하여 직접 만들기로 했다.

수정사항: typesafe-actions를 과거에 확인했을때는 이런 객체 맵핑 방식이 지원되지 않았었는데, 올해 4월에 업데이트된 4.1버전부터 지원되는 것을 확인했다. 미리 확인해볼걸 하는 후회가 든다..

직접 만들기

앞서서 redux-actions의 타입 정의가 엉망이라고 했지만 사실 과거에는 어쩔 수 없었다고 생각한다. string타입으로만 문자열을 사용할 수 있었으니까. 하지만 우리에게는 3.4버전부터 추가된 as const가 있다. 그걸 사용해서 비슷하지만, 타입정의를 정확하게 해주는 대체제를 만들어보자

handleActions

코드와 함께 보자.

// 리덕스이 표준 액션 객체를 사용한다.
import { Action } from "redux";

// `type`를 `string`이 아닌 제네릭으로 두어서 `type`의 타입을 유지하여 리턴한다.
// 정확한 액션 크리에이터 함수를 만들기 위해서 함수의 시그니쳐를 오버로딩한다.
// 1. 페이로드 크리에이터 함수가 정의되어 있는 경우
// 2. 페이로드 크리에이터 함수가 정의되어있지 않아 페이로드가 없는 경우
function createAction<T, P extends (...args: any) => any>(
  type: T,
  payloadCreator: P
): (...args: Parameters<P>) => Action<T> & { payload: ReturnType<P> };
function createAction<T>(type: T): () => Action<T>;
function createAction(type: any, payloadCreator?: any) {
  return (...args: any[]) => ({
    type,
    ...(payloadCreator && { payload: payloadCreator(...args) })
  });
}

export default createAction;

자 이렇게 하고 사용해보자. 사용법은 매우 유사하다.

const changeName = createAction(CHANGE_NAME, (name: string) => ({ name }));
const changeAge = createAction(CHANGE_AGE, (age: number) => ({ age }));

type UserActions = ReturnType<typeof changeName | typeof changeAge>;

한결 간결해지고, 단순히 페이로드만의 타입이 아닌, 액션의 타입 전부를 기억하고있는 액션 타입이 만들어졌다. 그 다음은 이것을 처리하는 handleActions의 차례이다.

handleActions

처음 타입스크립트를 사용하려고 했을때 이 함수를 어떻게든 만들어보려고 했었다. 결과는 실패로 돌아갔는데 as const의 은총으로 액션 타입에 대한 정확한 정의가 가능해진 지금은 정복이 가능해졌다.

// 역시 리덕스의 표준 액션 인터페이스를 사용한다.
import { Action } from "redux";

// 인자로 받을 리듀서의 키 밸류 맵이다.
// 리덕스의 액션 타입의 기본은 any로 되어있기 때문에 string으로 만든 액션을 상속받아서 사용한다.
type ReducerMap<A extends Action<string>, S> = {
  // 액션을 상속받은 A의 `type`의 타입들을 키로 사용한다.
  // 리듀서 함수의 인자로 넘어가는 액션은 그 키 타입에 맞는 액선을 끄집어내서(MatchedAction) 사용한다.
  [AT in A["type"]]?: (state: S, action: MatchedAction<A, AT>) => S;
} & { [key: string]: (state: S, action: Action) => S }; // 객체의 키 접근을 위한 인덱스 시그니쳐

// 인자로 받은 액션들(A) 중에 액션타입(T)이 일치하는 타입만 내보내고 나머지는 지운다.
type MatchedAction<A, T> = A extends Action<T> ? A : never;

const handleActions = <S, A extends Action<string>>(
  reducerMap: ReducerMap<A, S>,
  defaultState: S
) => (state = defaultState, action?: A): S =>
  action && reducerMap[action.type]
    ? reducerMap[action.type](state, action)
    : state;

export default matchAction;

다음은 사용 예이다. 역시 원본과 유사하게 사용하면 된다. 단, 지금은 액션에 맞추어서 필터해두었기 때문에 해당하는 액션 타입에 맞는 액션이 함수의 인자로 들어온다.

const user = handleActions<UserActions, User>(
  {
    // 정의된 액션들에 맞는 타입만 객체 키값으로 사용가능하고, 인자로 넘겨지는 액션도
    // 그 키에 맞는 액션이 전달되기때문에 타입에 안전하다.
    [CHANGE_NAME]: (state, { payload }) => ({
      ...state,
      name: payload.name
    })
  },
  defaultState
);

여기까지 간단하게 redux-actions의 대체 함수를 만들어보았다. 사실 구현되지 않은 기능들이 리덕스 액션에 많이 존재하지만 간단한 사용 용도로는 이정도 구현도 적절하리라 생각한다. 필요에 따라서 추가로 구현하거나 하면 될 것 같다. 타입스크립트를 사용하면서 redux-actions를 그리워 하는 사람들에게 도움이 되었으면 좋겠다.