타입스크립트 thunk, saga

thunk 적용하기

라이브러리 설치

npm i redux-thunk axios

index.js에 미들웨어 추가

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorker from './serviceWorker'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux'
import Thunk from 'redux-thunk'
import rootReducer from './modules'

const store = createStore(rootReducer, applyMiddleware(Thunk))

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

깃헙 사용자 정보 가져오기 예제 따라잡기

해당 깃헙의 사용자 정보를 get해오면 리턴하는 타입은 굉장히 많고 길다. 일일이 타입을 지정해주기 번거로울 수 있는데 Quicktype이라는 서비스를 이용하면 JSON을 타입스크립트 인터페이스로 바꿔준다.
참고 : https://app.quicktype.io/
여기서 주의할점은 바꿔준대로 항상 사용하는것이 아니라, 토큰 등 연동할때 타입이 바뀔 수 있으니 적절히 셋팅해주는것도 중요하다

src/api/github.ts

api 요청하는 모듈을 따로 생성해 지정해준다.

import axios from 'axios'

export async function getUserProfile(username: string) {
  const response = await axios.get<GithubProfile>(
    `https://api.github.com/users/${username}`
  )

  return response.data
}

export interface GithubProfile {
  login: string
  id: number
  node_id: string
  avatar_url: string
  gravatar_id: string
  url: string
  // ... 나머지 타입들
}

리덕스 모듈 만들기

액션 정의

src/modules/github/actions.ts

import { createAsyncAction } from 'typesafe-actions'
import { GithubProfile } from '../../api/github'
import { AxiosError } from 'axios'

export const GET_USER_PROFILE = 'github/GET_USER_PROFILE'
export const GET_USER_PROFILE_SUCCESS = 'github/GET_USER_PROFILE_SUCCESS'
export const GET_USER_PROFILE_ERROR = 'github/GET_USER_PROFILE_ERROR'

// export const getUserProfile = createAction(GET_USER_PROFILE)();
// export const getUserProfileSuccess = createAction(GET_USER_PROFILE_SUCCESS)<
//   GithubProfile
// >();
// export const getUserProfileError = createAction(GET_USER_PROFILE_ERROR)<
//   AxiosError
// >();

// createAsyncAction 사용 전 과 후

export const getUserProfileAsync = createAsyncAction(
  GET_USER_PROFILE,
  GET_USER_PROFILE_SUCCESS,
  GET_USER_PROFILE_ERROR
)<undefined, GithubProfile, AxiosError>()

GETUSERPROFILE은 요청이 시작되었을 때 디스패치되는 액션이며 나머지는 성공과 실패를 디스패치한다. createAction대신 createAsyncAction을 사용하면 묶어서 처리가 가능한데, 각각의 인자로 넣어준 타입에 맞게 제너릭에 순서를 매칭시켜 타입을 지정해준다. 추후 디스패치에서 request,success,failure에도 매칭이 되는듯하다.

thunk함수 만들기

modules/github/thunks.ts

import { ThunkAction } from 'redux-thunk'
import { RootState } from '..'
import { GithubAction } from './types'
import { getUserProfile } from '../../api/github'
import { getUserProfileAsync } from './actions'

export function getUserProfileThunk(
  username: string
): ThunkAction<void, RootState, null, GithubAction> {
  return async dispatch => {
    const { request, success, failure } = getUserProfileAsync
    dispatch(request())

    try {
      const userProfile = await getUserProfile(username)
      dispatch(success(userProfile))
    } catch (e) {
      dispatch(failure(e))
    }
  }
}

// ThunkAction의 제너릭은 다음값들을 순서대로

여기서 redux-thunk에서 ThunkAction을 불러와서 사용했는데, thunk함수의 리턴타입을 지정해주는데 사용하며, 제너릭에는 순서대로 값들을 지정해주어야한다 <TReturnType, Tstate, TextraThunkArg, TbasicAction>

  • TReturnType: thunk 함수에서 반환하는 값의 타입을 설정
  • Tstate: 스토어의 상태에 대한 타입 설정
  • TExtraThunkArg: redux-thunk 미들웨어의 Extra Argument의 타입 설정
  • TbasicAction: dispatch 할 수 있는 액션들의 타입 설정

TReturnType이 아무것도 반환하지 않는다면 void를 넣어주면 되지만 thunk함수에서 async를 사용하니 Promise가 정확하긴 하다. 다만 void도 문제없음

리듀서 작성

리듀서 작성전 모듈에서 관리할 상태 타입 선언
src/modules/github/types.ts

import * as actions from './actions'
import { ActionType } from 'typesafe-actions'
import { GithubProfile } from '../../api/github'

export type GithubAction = ActionType<typeof actions>

export type GithubState = {
  userProfile: {
    loading: boolean
    error: Error | null
    data: GithubProfile | null
  }
}

리듀서
src/modules/github/reducer.ts

import { createReducer } from 'typesafe-actions'
import { GithubState, GithubAction } from './types'
import {
  GET_USER_PROFILE,
  GET_USER_PROFILE_SUCCESS,
  GET_USER_PROFILE_ERROR,
} from './actions'

const initialState: GithubState = {
  userProfile: {
    loading: false,
    error: null,
    data: null,
  },
}

const github = createReducer<GithubState, GithubAction>(initialState, {
  [GET_USER_PROFILE]: state => ({
    ...state,
    userProfile: {
      loading: true,
      error: null,
      data: null,
    },
  }),
  [GET_USER_PROFILE_SUCCESS]: (state, action) => ({
    ...state,
    userProfile: {
      loading: false,
      error: null,
      data: action.payload,
    },
  }),
  [GET_USER_PROFILE_ERROR]: (state, action) => ({
    ...state,
    userProfile: {
      loading: false,
      error: action.payload,
      data: null,
    },
  }),
})

export default github

index.ts에서 모듈 내보내기
src/modules/github/index.ts

export { default } from './reducer'
export * from './actions'
export * from './types'
export * from './thunks'

위와같은 방식으로 모듈을 내보내주는것도 확인할 수 있다.

사용자 정보를 불러오는 프레젠테이셔널 컴포넌트

src/components/GithubUsernameForm.tsx

import React, { FormEvent, useState, ChangeEvent } from 'react'

type GithubUsernameFormProps = {
  onSubmitUsername: (username: string) => void
}

function GithubUsernameForm({ onSubmitUsername }: GithubUsernameFormProps) {
  const [input, setInput] = useState('')

  const onSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    onSubmitUsername(input)
  }

  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value)
  }

  return (
    <form className="GithubUsernameForm" onSubmit={onSubmit}>
      <input
        onChange={onChange}
        value={input}
        placeholder="Github 계정명 입력란"
      />
      <button type="submit">조회</button>
    </form>
  )
}

export default GithubUsernameForm

계정에대한 정보를 보여주는 프로필 컴포넌트 작성
src/components/GithubProfileInfo.tsx

type GithubProfileInfoProps = {
  name: string
  thumbnail: string
  bio: string
  blog: string
}

function GithubProfileInfo({
  name,
  thumbnail,
  bio,
  blog,
}: GithubProfileInfoProps) {
  return (
    <div className="GithubProfileInfo">
      <div className="profile-head">
        <img src={thumbnail} alt="user thumbnail" />
        <div className="name"> {name} </div>
      </div>
      <p>{bio}</p>
      <div>{blog !== '' && <a href={blog}>블로그</a>}</div>
    </div>
  )
}

export default GithubProfileInfo

중요한 컨테이너 컴포넌트
src/containers/GithubProfileLoader.tsx

import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../modules'
import GithubUsernameForm from '../components/GithubUsernameForm'
import GithubProfileInfo from '../components/GithubProfileInfo'
import { getUserProfileThunk } from '../modules/github'

function GithubProfileLoader() {
  const { data, loading, error } = useSelector(
    (state: RootState) => state.github.userProfile
  )
  const dispatch = useDispatch()

  const onSubmitUsername = (username: string) => {
    dispatch(getUserProfileThunk(username))
  }

  return (
    <>
      <GithubUsernameForm onSubmitUsername={onSubmitUsername} />
      {loading && <p style={{ textAlign: 'center' }}>로딩중..</p>}
      {error && <p style={{ textAlign: 'center' }}>에러 삐용삐용</p>}
      {data && (
        <GithubProfileInfo
          bio={data.bio}
          blog={data.blog}
          name={data.name}
          thumbnail={data.avatar_url}
        />
      )}
    </>
  )
}

export default GithubProfileLoader

Promise기반 thunk를 만들어주는 createAsyncThunk 만들어 리팩토링 하기

createAsyncAction으로 만든 액션 생성함수와 Promise를 만들어주는 함수를 파라미터로 받아와서 thunk를 만든다.
src/lib/createAsyncThunk.ts

import { Dispatch } from 'redux'
import { AsyncActionCreatorBuilder } from 'typesafe-actions'
// 빌더 이전 AsyncActionCreator

type AnyAsyncActionCreator = AsyncActionCreatorBuilder<any, any, any>

export default function createAsyncThunk<
  A extends AnyAsyncActionCreator,
  F extends (...params: any[]) => Promise<any>
>(AsyncActionCreator: A, promiseCreator: F) {
  type Params = Parameters<F>
  return function thunk(...params: Params) {
    return async (dispatch: Dispatch) => {
      const { request, success, failure } = AsyncActionCreator
      dispatch(request(undefined)) // 파라미터가 없을떄 타입 에러가 나기때문에 전달용 언디파인드
      try {
        const result = await promiseCreator(...params)
        dispatch(success(result))
      } catch (e) {
        dispatch(failure(e))
      }
    }
  }
}

뭔가 복잡해보인다. 함수에서 제너릭으로 A, F의 타입을 받고 각각 파라미터에 할당된다. F extends (…params: any[]) => Promise는 F를 제너릭으로 받아오는데 해당 타입은 프로미스를 리턴하는 함수형태만 받아올 수 있도록 설정한다. type Params = Parameters는 함수의 파라미터들의 타입을 추론해준다. 이를 통해 F함수의 파라미터와 thunk의 파라미터가 동힐하게끔 설정할 수 있다.
이 부분은 어렵다. ts가 역시 많이 부족하다.

import { getUserProfile } from '../../api/github'
import { getUserProfileAsync } from './actions'
import createAsyncThunk from '../../lib/createAsyncThunk'

export const getUserProfileThunk = createAsyncThunk(
  getUserProfileAsync,
  getUserProfile
)

thunk.ts를 리팩토링한 결과이다. 간결해지는게 눈에 확연히 보이는데, 이상하게 getUserProfileAsync쪽이 타입추론이 잘 되지 않는다. 원인을 찾아보는중인데 아직 찾지 못했다. Builder와 creator의 차이인지 모르겠다.

리듀서 리팩토링

API 요청에 관련된 상태를 { loading, error ,data} 형태로 관리하고 있는것을 더 쉽게 만드는 유틸 함수
lib/reducerUtils.ts

export type AsyncState<T, E = any> = {
  data: T | null
  loading: boolean
  error: E | null
}

export 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,
  }),
}

GithubState의 타입을 수정

import * as actions from './actions'
import { ActionType } from 'typesafe-actions'
import { GithubProfile } from '../../api/github'
import { AsyncState } from '../../lib/reducerUtils'

export type GithubAction = ActionType<typeof actions>

export type GithubState = {
  userProfile: AsyncState<GithubProfile, Error>
}

리듀서 수정

import { createReducer } from 'typesafe-actions'
import { GithubState, GithubAction } from './types'
import {
  GET_USER_PROFILE,
  GET_USER_PROFILE_SUCCESS,
  GET_USER_PROFILE_ERROR,
} from './actions'
import { asyncState } from '../../lib/reducerUtils'

const initialState: GithubState = {
  userProfile: asyncState.initial(),
}

const github = createReducer<GithubState, GithubAction>(initialState, {
  [GET_USER_PROFILE]: state => ({
    ...state,
    userProfile: asyncState.load(),
  }),
  [GET_USER_PROFILE_SUCCESS]: (state, action) => ({
    ...state,
    userProfile: asyncState.success(action.payload),
  }),
  [GET_USER_PROFILE_ERROR]: (state, action) => ({
    ...state,
    userProfile: asyncState.error(action.payload),
  }),
})

export default github

확실히 깔끔해진것같긴한데… 이해가 잘 되지 않는다. 그냥 아직 우선적으로 타입스크립트 개념이 부족하고 thunk연동하면서 사용하는 typesafe-actions도 생소해서 눈에 잘 안들어오고 이해가 어려운것같다.

redux-saga

썽크에서 사가로 바꿔주려면 GETUSERPROFILE에서 payload로 사용자명을 받아와야한다.
github/actions.ts

export const getUserProfileAsync = createAsyncAction(
  GET_USER_PROFILE,
  GET_USER_PROFILE_SUCCESS,
  GET_USER_PROFILE_ERROR
)<string, GithubProfile, AxiosError>()

기존에 undefined를 string으로 바꿔야한다.

사가 작성

modules/github/saga.ts

import { getUserProfileAsync, GET_USER_PROFILE } from './actions'
import { getUserProfile, GithubProfile } from '../../api/github'
import { call, put, takeEvery } from 'redux-saga/effects'

function* getUserProfileSaga(
  action: ReturnType<typeof getUserProfileAsync.request>
) {
  try {
    const userProfile: GithubProfile = yield call(
      getUserProfile,
      action.payload
    )
    yield put(getUserProfileAsync.success(userProfile))
  } catch (e) {
    yield put(getUserProfileAsync.failure(e))
  }
}

export function* githubSaga() {
  yield takeEvery(GET_USER_PROFILE, getUserProfileSaga)
}

액션들의 타입은 ReturnType을 통해서 유추한다. yield call을 통해 프로미스를 만드는 특정 함수를 호출했을 때, 프로미스의 결과에 대한 타입을 유추하지 못한다.따라서 결과값은 force type을 통해 타입을 지정해주어야 한다.
그 후 github/index.ts에서 모듈을 불러와 내보낸다.

...
export * from './saga'

그 후 루트사가 작성
modules/index.ts

import { combineReducers } from 'redux'
import counter from './counter'
import todos from './todos'
import github from './github'
import { githubSaga } from './github'
import { all } from 'redux-saga/effects'

const rootReducer = combineReducers({
  counter,
  todos,
  github,
})

export default rootReducer

// 루트 리듀서의 반환 타입 잡기
export type RootState = ReturnType<typeof rootReducer>

export function* rootSaga() {
  yield all([githubSaga()])
}

index.tsx에 스토어 생성에서 사가를 적용한다.

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorker from './serviceWorker'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import rootReducer, { rootSaga } from './modules'

const sagaMiddleware = createSagaMiddleware()

sagaMiddleware.run(rootSaga)

const store = createStore(rootReducer, applyMiddleware(sagaMiddleware))

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

위와같이 적용하면 thunk를 사용할떄처럼 잘 작동하는것을 확인할 수 있다.

이번엔 사가 리팩토링

lib/createAsyncSaga.ts

import { call, put } from 'redux-saga/effects'
import {
  AsyncActionCreatorBuilder,
  PayloadAction,
  action,
} from 'typesafe-actions'

// 유틸함수의 재사용성을 높이기 위해 함수의 파라미터는 언제나 하나의 값을 사용,
// action.payload 를 그대로 파라미터러 넣어줌, 여러종류의 값을 파라미터로 넣어야 한다면 객체 형태로
type PromiseCreatorFunction<P, T> =
  | ((payload: P) => Promise<T>)
  | (() => Promise<T>)

// 액션이 페이로드를 갖고 있는지 확인
// __ is __ 문법은 type guard https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-guards-and-type-assertions
function isPayloadAction<P>(action: any): action is PayloadAction<string, P> {
  return action.payload !== undefined
}

export default function createAsyncSaga<T1, P1, T2, P2, T3, P3>(
  asyncActionCreator: AsyncActionCreatorBuilder<
    [T1, [P1]],
    [T2, [P2]],
    [T3, [P3]]
  >,
  promiseCreator: PromiseCreatorFunction<P1, P2>
) {
  return function* saga(action: ReturnType<typeof asyncActionCreator.request>) {
    try {
      const result = isPayloadAction<P1>(action)
        ? yield call(promiseCreator, action.payload)
        : yield call(promiseCreator)
      yield put(asyncActionCreator.success(result))
    } catch (e) {
      yield put(asyncActionCreator.failure(e))
    }
  }
}

처음 보는 문법도 있고, AsyncActionCreatorBuilder쪽 제너릭 타입도 생소했다. 썽크와 사가를 따라하며 느낀점이 리팩토링하며 재사용성을 높이는 코드를 구현할 때, 조금 더 타입지정이 까다로운 느낌이다. 지속적으로 보면서 눈에 익게 해야겠다. 아직 많이 어렵다.

saga.ts 수정

import { getUserProfileAsync, GET_USER_PROFILE } from './actions'
import { getUserProfile } from '../../api/github'
import { takeEvery } from 'redux-saga/effects'
import createAsyncSaga from '../../lib/createAsyncSaga'

const getUserProfileSaga = createAsyncSaga(getUserProfileAsync, getUserProfile)

export function* githubSaga() {
  yield takeEvery(GET_USER_PROFILE, getUserProfileSaga)
}

이 역시 확실히 깔끔해졌다. 그냥 우선적 기능구현까지는 어느정도 따라할만 한데, 역시 리팩토링 과정이 이해가 잘 안되고 어려웠다. 자주 해보면 익숙해지고 편해지지 않을까 생각해보며 꾸준히 봐야겠다.


Written by@jaeyoung-son
배운것을 기록하는 공간입니다.

GitHub