August 05, 2020
npm i redux-thunk axios
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/
여기서 주의할점은 바꿔준대로 항상 사용하는것이 아니라, 토큰 등 연동할때 타입이 바뀔 수 있으니 적절히 셋팅해주는것도 중요하다
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에도 매칭이 되는듯하다.
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이 아무것도 반환하지 않는다면 void를 넣어주면 되지만 thunk함수에서
async를 사용하니 Promise
리듀서 작성전 모듈에서 관리할 상태 타입 선언
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
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
이 부분은 어렵다. 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도 생소해서 눈에 잘 안들어오고 이해가 어려운것같다.
썽크에서 사가로 바꿔주려면 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쪽 제너릭 타입도 생소했다. 썽크와 사가를 따라하며 느낀점이 리팩토링하며 재사용성을 높이는 코드를 구현할 때, 조금 더 타입지정이 까다로운 느낌이다. 지속적으로 보면서 눈에 익게 해야겠다. 아직 많이 어렵다.
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)
}
이 역시 확실히 깔끔해졌다. 그냥 우선적 기능구현까지는 어느정도 따라할만 한데, 역시 리팩토링 과정이 이해가 잘 안되고 어려웠다. 자주 해보면 익숙해지고 편해지지 않을까 생각해보며 꾸준히 봐야겠다.