redux-thunk
설치
$ yarn add redux-thunk
axios
설치
$ yarn add axios
(API 요청 목적)
모두 공식적으로 타입스크립트 지원됨
→ @types/redux-thunk
나 @types/axios
를 따로 설치할 필요 x
// src/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')
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
목표
GitHub의 사용자 정보를 가져오는 기능 구현
사용할 API
👉 GET https://api.github.com/users/:username
:username
여기에 사용자의 유저네임(조회하고자 하는) 넣기
ex. GET https://api.github.com/users/velopert
결과물
{
"login": "velopert",
"id": 17202261,
"node_id": "MDQ6VXNlcjE3MjAyMjYx",
"avatar_url": "https://avatars0.githubusercontent.com/u/17202261?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/velopert",
"html_url": "https://github.com/velopert",
"followers_url": "https://api.github.com/users/velopert/followers",
"following_url": "https://api.github.com/users/velopert/following{/other_user}",
"gists_url": "https://api.github.com/users/velopert/gists{/gist_id}",
"starred_url": "https://api.github.com/users/velopert/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/velopert/subscriptions",
"organizations_url": "https://api.github.com/users/velopert/orgs",
"repos_url": "https://api.github.com/users/velopert/repos",
"events_url": "https://api.github.com/users/velopert/events{/privacy}",
"received_events_url": "https://api.github.com/users/velopert/received_events",
"type": "User",
"site_admin": false,
"name": "Minjun Kim",
"company": "@laftel-team ",
"blog": "https://velopert.com/",
"location": null,
"email": null,
"hireable": null,
"bio": "개발은 언제나 즐겁고 재밌어야 한다는 생각을 갖고 있는 개발자이며, 가르치는것을 굉장히 좋아하는 교육자이기도 합니다.",
"public_repos": 64,
"public_gists": 31,
"followers": 1016,
"following": 16,
"created_at": "2016-02-12T16:43:22Z",
"updated_at": "2019-09-04T16:23:39Z"
}
응답된 데이터에 대한 타입
응답된 데이터에 대한 타입을 준비 해야 함.
준비 방법
JSON
을 바로 타입스크립트 인터페이스로 변환 가능
수정해줘야 할 가능성 有
실제 사용 케이스에 맞춰 수정 할 필요성 있을 수도 있다. (변환된 인터페이스를 그대로 사용x)
예시
JSON
내부의 email
값
현재 : null
API 요청 시 (GitHub API 토큰 사용 → 인증된 계정을 통해 ) : 문자열이 올 수도 있음.
실무에서 비슷한 상황 있을 가능성 有 (타입스크립트 사용 & 백엔드와 연동 시)
참고: 지금은 그대로 사용할 것
위치
src/api/github.ts
코드
import axios from 'axios';
export async function getUserProfile(username: string) {
// Generic 을 통해 응답 데이터의 타입을 설정 할 수 있습니다.
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;
html_url: string;
followers_url: string;
following_url: string;
gists_url: string;
starred_url: string;
subscriptions_url: string;
organizations_url: string;
repos_url: string;
events_url: string;
received_events_url: string;
type: string;
site_admin: boolean;
name: string;
company: string;
blog: string;
location: null;
email: null;
hireable: null;
bio: string;
public_repos: number;
public_gists: number;
followers: number;
following: number;
created_at: Date;
updated_at: Date;
}
▼ 코드
src/modules/github/actions.ts
import { deprecated } from "typesafe-actions";
const { createStandardAction } = deprecated;
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 = createStandardAction(GET_USER_PROFILE)();
export const getUserProfileSuccess = createStandardAction(
GET_USER_PROFILE_SUCCESS
)<GithubProfile>();
export const getUserProfileError = createStandardAction(
GET_USER_PROFILE_ERROR
)<AxiosError>();
▼ 리팩토링
createAsyncAction 유틸함수 활용 (typesafe-actions
)
필수 x
편함 (반복되는 코드를 덜 입력)
코드
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 getUserProfileAsync = createAsyncAction(
GET_USER_PROFILE,
GET_USER_PROFILE_SUCCESS,
GET_USER_PROFILE_ERROR
)<any, GithubProfile, AxiosError>();
[ 에러 임시 조치 ] undefined
에서 any
로 바꾼 이유
createAsyncAction
에서 Request type
을 정할 때 undefined
로 하는 바람에 type inference
가 EmptyActionCreator
로 되어서 에러 발생한 것으로 추정.
원래 AsyncActionCreatorBuilder
로 만들어진 type
은 PayloadActionCreator
여야 함. 우선 createAsyncAction
에서 Request type
이 undefined
로 되어 있는 걸 any
로 바꾸니까 되서 임시 조치함.
▼ 코드
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 의 Generics 순서
👉 <TReturnType, TState, TExtraThunkArg, TBasicAction>
TReturnType
thunk
함수의 반환 값의 타입을 설정
참고
아무것도 반환 하지 않는 경우, void
넣음
현재 : Promise<void>
가 더 정확
(이유:thunk
함수에서 async
사용 중)
(but, void
사용 문제 없음)
TState
스토어 상태에 대한 타입 설정
TExtraThunkArg
redux-thunk
미들웨어의 Extra Argument의 타입을 설정
TBasicAction
dispatch
할 수 있는 액션들의 타입을 설정
▼ 코드
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
}
})
});
index
▼ 코드
src/modules/github/index.ts
export { default } from './reducer';
export * from './actions';
export * from './types';
export * from './thunks';
▼ 코드
src/modules/index.ts
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';
import github from './github';
const rootReducer = combineReducers({
counter,
todos,
github
});
// 루트 리듀서를 내보내주세요.
export default rootReducer;
// 루트 리듀서의 반환값를 유추해줍니다
// 추후 이 타입을 컨테이너 컴포넌트에서 불러와서 사용해야 하므로 내보내줍니다.
export type RootState = ReturnType<typeof rootReducer>;
src/components/GithubUsernameForm.tsx
import React, { FormEvent, useState, ChangeEvent } from 'react';
import './GithubUsernameForm.css';
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/GithubUsernameForm.css
.GithubUsernameForm {
width: 400px;
display: flex;
align-items: center;
height: 32px;
margin: 0 auto;
margin-top: 16px;
margin-bottom: 48px;
}
.GithubUsernameForm input {
flex: 1;
border: none;
outline: none;
border-bottom: 1px solid black;
font-size: 21px;
height: 100%;
margin-right: 1rem;
}
.GithubUsernameForm button {
background: black;
color: white;
cursor: pointer;
outline: none;
border: none;
border-radius: 4px;
font-size: 16px;
padding-left: 16px;
padding-right: 16px;
height: 100%;
font-weight: bold;
}
.GithubUsernameForm button:hover {
background: #495057;
}
사용자 계정에 대한 정보 보여주기
(이름, 프로필 사진, 자기소개, 블로그 링크(링크 있는 경우만 렌더링))
src/components/GithubProfileInfo.tsx
import React from 'react';
import './GithubProfileInfo.css';
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/components/GithubProfileInfo.css
.GithubProfileInfo {
width: 400px;
margin: 0 auto;
}
.GithubProfileInfo .profile-head {
display: flex;
align-items: center;
}
.GithubProfileInfo .profile-head img {
display: block;
width: 64px;
height: 64px;
border-radius: 32px;
margin-right: 1rem;
}
.GithubProfileInfo .profile-head .name {
font-weight: bold;
}
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;
src/App.tsx
import React from 'react';
import GithubProfileLoader from './containers/GithubProfileLoader';
const App: React.FC = () => {
return <GithubProfileLoader />;
};
export default App;
참고