[React] 타입스크립트로 Redux 함께 쓰기

js43o·2021년 11월 29일
0
post-custom-banner

프로젝트 구조

src
ㄴapi
  -client.ts
  -post.ts
ㄴcomponents
  -PostApp.tsx
  -PostForm.tsx
  -PostView.tsx
ㄴmodules
  -index.ts
  -types.ts
  -actions.ts
  -reducer.ts
  -saga.ts
App.tsx
index.tsx

api/post.ts

import client from './client';
// client = axios.create({ ... });

export async function getPostById(id: string) {
  const response = await client.get<responsePayloadType>(`/posts/${id}`);
  return response.data;
}

export type requestPropsType = {
  id: string;
};

export type responsePayloadType = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

비동기 함수 getPostById()는 axios 객체를 이용해 API 요청을 받아오고 있다.
JS와 달리 타입스크립트에서 API 요청을 할 때에는 API의 request와 response에 관한 타입을 따로 정의해야 한다.

modules/types.ts

import { ActionType } from 'typesafe-actions';
import { initialize, getPost } from './actions';

const actions = { initialize, getPost };
export type PostAction = ActionType<typeof actions>;

export type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

export type PostState = {
  loading: boolean;
  posts: Post[];
  color: string;
};

리덕스 모듈에 쓰이는 상태와 액션의 타입을 정의한다.

modules/actions.ts

import { createAction, createAsyncAction } from 'typesafe-actions';
import { requestPropsType, responsePayloadType } from '../api/post';
import { AxiosError } from 'axios';

export const INITIALIZE = 'INITIALIZE';
export const GET_POST = 'GET_POST';
export const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
export const GET_POST_FAILURE = 'GET_POST_FAILURE';

export const initialize = createAction(INITIALIZE)();
export const getPost = createAsyncAction(
  GET_POST,
  GET_POST_SUCCESS,
  GET_POST_FAILURE,
)<requestPropsType, responsePayloadType, AxiosError>();

액션 객체 및 액션 생성 함수를 정의한다.

modules/reducer.ts

import { createReducer } from 'typesafe-actions';
import { PostAction, PostState } from './types';
import {
  GET_POST,
  INITIALIZE,
  GET_POST_SUCCESS,
  GET_POST_FAILURE,
} from './actions';

const initialState: PostState = {
  loading: false,
  posts: [],
  color: 'white',
};

const post = createReducer<PostState, PostAction>(initialState, {
  [INITIALIZE]: (state) => ({ ...state, posts: [] }),
  [GET_POST]: (state) => ({ ...state, loading: true }),
  [GET_POST_SUCCESS]: (state, action) => ({
    ...state,
    loading: false,
    posts: state.posts.concat(action.payload),
  }),
  [GET_POST_FAILURE]: (state, action) => ({
    ...state,
    loading: false,
    error: action.payload,
  }),
});

export default post;

리듀서를 작성할 때에는 상태 및 액션의 타입을 필요로 한다.


* typesafe-actions

리덕스의 보일러 플레이트 코드를 더욱 간편하게 작성하기 위한 라이브러리.

// 액션 타입 객체
// 뒤에 as const를 붙이지 않아도 됨
const ACTION_TYPE = 'module/action';

// 액션 생성 함수
// 반드시 뒤에 ()를 붙여서 호출해야 정상 작동
const actionCreator = createAction(ACTION_TYPE)(map props to payload)();

// 비동기 액션 생성 함수
// 액션의 요청, 성공, 실패를 한 번에 다룰 수 있음
const asyncActionCreator = createAsyncAction(
  ACTION_REQUEST_TYPE,
  ACTION_SUCCESS_TYPE,
  ACTION_FAILURE_TYPE
)<actionRequestPayload, actionResponsePayload, actionErrorPayload>();

// 리듀서에서 사용할 전체 액션 타입
const actions = { actionCreator, ... };
type Action = ActionType<typeof actions>;

// 리듀서
const reducer = createReducer<State, Action>(initialState, {
  [ACTION_TYPE]: (state, action) => ({ ... }),
  ...
});

또는

const reducer = createReducer<State, Action>(initialState)
  .handleAction(ACTION_TYPE, (state, action) => ({ ... }))
  .handleAction( ... )
  ...;

redux-saga

modules/saga.ts

import { AxiosError } from 'axios';
import { call, put, takeLatest } from 'redux-saga/effects';
import * as api from '../api/post';
import { getPost } from './actions';

function* getPostByIdSaga(action: ReturnType<typeof getPost.request>) {
  try {
    const response: api.responsePayloadType = yield call(
      [undefined, api.getPostById],
      action.payload.id,
    );
    yield put(getPost.success(response));
  } catch (e) {
    yield put(getPost.failure(e as AxiosError));
  }
}

export default function* postSaga() {
  yield takeLatest(getPost.request, getPostByIdSaga);
}

비동기 작업을 위한 redux-saga의 각 saga는 요청 액션 타입을 인수로 받는다.

call은 API 호출을 위해 사용되었다. 첫 번째 인수로는 Context와 API 호출 함수로 이루어진 배열을, 두 번째 인수로는 요청 시 호출 함수에 필요한 인수를 제공한다.

put은 saga 안에서 dispatch와 비슷한 역할을 한다. 성공/실패 시 각 상황에 맞는 액션 생성 함수를 인수와 함께 넣어 호출한다.

takeLatest는 첫 번째 인수로 제공된 액션을 감지하면 두 번째 인수의 saga를 실행한다. 해당 액션이 동시에 여러 번 발생하면 가장 마지막으로 발생한 액션만 감지한다.

redux-thunk

modules/thunk.ts

export const getPostByIdThunk =
  (id: string): ThunkAction<void, PostState, null, PostAction> =>
  async (dispatch) => {
    dispatch(getPost.request());
    try {
      const response = await api.getPostById(id);
      dispatch(getPost.success(response));
    } catch (e) {
      dispatch(getPost.failure(e as AxiosError));
    }
  };
  
export const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  dispatch(getPostByIdThunk(value)); // 함수를 디스패치
};

redux-saga 대신 redux-thunk를 이용하면 이렇게 작성한다.

modules/index.ts

import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import post from './reducer';
import postSaga from './saga';

const rootReducer = combineReducers({
  post,
  ...
});

export default rootReducer;

export type RootState = ReturnType<typeof rootReducer>;

export function* rootSaga() {
  yield all([postSaga()]);
  ...
}

리덕스 모듈의 인덱스에서는 각 리듀서와 사가를 하나로 합쳐 내보낸다. 이때 rootReducer의 반환 타입인 RootState도 함께 내보내준다.

index.ts

import React from 'react';
import ReactDOM from 'react-dom';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import createSagaMiddleware from 'redux-saga';
import ReduxThunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import App from './App';
import rootReducer, { rootSaga } from './modules';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(sagaMiddleware, ReduxThunk)),
);
sagaMiddleware.run(rootSaga); // saga 미들웨어 실행

ReactDOM.render(
  <Provider store={store}>
    <React.StrictMode>
      <App />
    </React.StrictMode>
  </Provider>,
  document.getElementById('root'),
);
profile
공부용 블로그
post-custom-banner

0개의 댓글