최근 typescript로 react앱을 만들어 보던 중 redux, 특히 redux-saga 활용 시 불편한 점이 많아 이것저것 찾아보다보니 typesafe-actions라는 패키지가 있어서 간단한 활용법을 소개하고자 한다.
typesafe-actions의 createAction 함수를 활용한다.
import { createAction } from 'typesafe-actions';
const CHANGE_INPUT = 'CHANGE_INPUT';
const changeInput = createAction(CHANGE_INPUT, ({ key, value }) => ({ key, value }))();
중요한 점은 createAction함수를 반드시 호출 해줘야 한다는 점이다.
기존 redux-actions에 익숙해져서 자꾸 저 호출부를 빠트려 에러 뜬적이 정말 많았다.
createAsyncAction 함수를 활용한다.
import { createAsyncAction } from 'typesafe-actions';
// login request action에 대한 payload type
interface LoginPayload {
username: string;
password: string;
}
// Login request success 시의 response type
interface LoginResponse {
_id: string;
username: string;
}
const LOGIN_REQUEST = 'LOGIN_REQUEST';
const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
const LOGIN_FAILURE = 'LOGIN_FAILURE';
const loginAsync = createAsyncAction(
LOGIN_REQUEST,
LOGIN_SUCCESS,
LOGIN_FAILURE
)<LoginPayload, LoginResponse, Error>();
axios를 활용하는 경우 Response는 AxiosResponse<응답타입>, AxiosError
형식을 generic의 인수로 입력해주면 된다.
const loginAsync = createAsyncAction(
LOGIN_REQUEST,
LOGIN_SUCCESS,
LOGIN_FAILURE
)<LoginPayload, AxiosResponse<LoginResponse>, AxiosError>();
마찬가지로 반드시 createAsyncAction 함수를 호출해주어야 액션 생성함수가 정상적으로 생성된다.
참고로 네번째 인자에 cancel에 대한 액션을 별도로 추가할 수 있다.(optional)
createReducer 함수를 활용한다.
import { createReducer } from 'typesafe-actions';
interface State {
username: string;
password: string;
login: LoginResponse | null;
const initialState = {
username: '',
password: '',
login: null
}
const login = createReducer(initialState)
.handleAction(changeInput, (state, { payload: { key, value } }) => ({
...state,
[key]: value
}))
.handleAction(actionCreator, reducer)
.handleAction([actionCreator1, actionCreator2], reducer)
handleAction chain API를 활용하여 복수의 case에 대한 reducer 함수를 등록할 수 있다.
액션 생성함수는 배열의 형태로 여러개를 한번에 입력하는것도 가능하다.
const login = createReducer(initialState, {
[CHANGE_INPUT]: (state, action) => ({
...state,
[action.payload.key]: action.payload.value
})
})
이런식으로도 작성 가능하다.
saga생성은 별 다를건 없다.
function *loginSaga(action: ReturnType<typeof loginAsync.request>): Generator {
try {
const response: LoginResponse = yield call(loginAPI.login, action.payload);
yield put(loginAsync.success(response);
} catch(e) {
yield put(loginAsync.failure(e)
}
}
createAsyncAction함수를 통해 생성된 비동기 액션 생성함수를 입력 받아 saga를 리턴하는 함수를 만들어보자.
먼저 Request API에 대한 타입을 정의한다.
// api별로 argument가 있을수도 없을수도 있음
type PromiseCreatorFunction<P, T> =
| ((payload: P) => Promise<T>)
| (() => Promise<T>);
그다음 해당 액션의 payload 존재 유무를 확인하는 함수를 작성한다.
function isPayloadAction<P>(action: any): action is PayloadAction<string, P> {
return action.payload !== undefined;
}
위 함수를 통해 해당 액션이 payload를 가지고 있는지 확인할 수 있다.
function createAsyncSaga<P1, P2, P3>(
asyncActionCreator: AsyncActionCreatorBuilder<
[string, [P1, undefined]],
[string, [P2, undefined]],
[string, [P3, undefined]]
>,
promiseCreator: PromiseCreatorFunction<P1, P2>
) {
return function* saga(
action: ReturnType<typeof asyncActionCreator.request>
) {
try {
const response: P2 = isPayloadAction<P1>(action) // payload 존재여부 확인
? yield call(promiseCreator, action.payload)
: yield call(promiseCreator);
yield put(asyncActionCreator.success(response.data);
} catch(e) {
yield put(asyncActionCreator.failure(e));
}
}
}
AsyncActionCreatorBuilder
는 typesafe-actions의 내장 타입으로 generic에 3가지 변수를 받는다. 각각 [request type, [payload, meta]], [success type, [payload, meta]], [failure type, [payload, meta]] 형태로 입력해주면 된다.
asyncAction이 dispatch될 때의 action별 reducer를 utility함수를 만들어 처리해보자.
먼저 비동기 요청에 대한 상태를 미리 정의해둔다.
type AsyncState<T, E = any> = {
data: T | null;
loading: boolean;
error: E | null;
};
const asyncState = {
initial: <T, E = any>(initialData?: T): AsyncState<T, E> => ({
loading: false,
data: initialData || null,
error: null,
}),
load: <T, E = any>(data?: T): AsyncState<T, E> => ({
loading: true,
data: data || null,
error: null,
}),
success: <T, E = any>(data: T): AsyncState<T, E> => ({
loading: false,
data,
error: null,
}),
error: <T, E>(error: E): AsyncState<T, E> => ({
loading: false,
data: null,
error: error,
}),
};
비동기 요청에 대한 상태는 loading, data, error 속성을 가지며 initial, load, success, error 단계별로 상태를 미리 정의해 두었다.
type AnyAsyncActionCreator = AsyncActionCreatorBuilder<any, any, any>;
export function transformToArray(
asyncActionCreator: AnyAsyncActionCreator
) {
const { request, success, failure } = asyncActionCreator;
return [request, success, failure];
}
export function createAsyncReducer<
S,
AC extends AnyAsyncActionCreator,
K extends keyof S
>(asyncActionCreator: AC, key: K) {
const [request, success, failure] = transformToArray(
asyncActionCreator,
).map(getType);
return {
[request]: (state: S) => ({
...state,
[key]: asyncState.load(),
}),
[success]: (
state: S,
action: ReturnType<typeof asyncActionCreator.success>,
) => ({
...state,
[key]: asyncState.success(action.payload),
}),
[failure]: (
state: S,
action: ReturnType<typeof asyncActionCreator.failure>,
) => ({
...state,
[key]: asyncState.error(action.payload),
}),
};
}
getType은 typesafe-actions의 내장함수로 인수로 제공된 액션 생성함수의 액션 type을 리턴한다.
transformToArray함수를 통해 asyncActionCreator를 배열 형태로 변환하고 각 액션 생성함수의 액션 type을 받아 request, success, failure 변수에 저장해둔다.
그런 다음 액션 타입별로 리듀서 함수를 정의해두었다.
이제 위에서 만든 함수를 활용하여 비동기 액션에 대한 reducer를 만들어 보자.
interface LoginState {
username: string;
password: string;
login: AsyncState<Response, Error>
}
const login = createReducer<LoginState>(initialState, {
...createAsyncReducer(loginAsync, 'login'),
[CHANGE_INPUT]: (state, action) => ({
...state,
[action.payload.key]: action.payload.value
})
})
createAsyncReducer와 일반 action을 handleAction chain API로 연결시키면 타입에러가 뜨는데 원인은 더 찾아봐야 할 듯 하다.
하나의 module에 동기식과 비동기식 액션이 같이 있는 경우 위 처럼 작성해주면 된다.
velopert님의 github 'ts-react-redux-tutorial'
typesafe-actions 공식문서