Redux-thunk+TS 01 | Basics (리덕스 모듈 / 프레젠테이셔널 / 컨테이너)

Kate Jung·2022년 1월 7일
0

middlewares & libraries

목록 보기
14/17
post-thumbnail

📌 redux-thunk 적용

🔹 설치

  • 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 사용자 정보 가져오기

🔹 준비

목표

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"
    }

응답된 데이터에 대한 타입

  • 응답된 데이터에 대한 타입을 준비 해야 함.

  • 준비 방법

  • 수정해줘야 할 가능성 有

    실제 사용 케이스에 맞춰 수정 할 필요성 있을 수도 있다. (변환된 인터페이스를 그대로 사용x)

    • 예시

      JSON 내부의 email 값

      • 현재 : null

      • API 요청 시 (GitHub API 토큰 사용 → 인증된 계정을 통해 ) : 문자열이 올 수도 있음.

    • 실무에서 비슷한 상황 있을 가능성 有 (타입스크립트 사용 & 백엔드와 연동 시)

    • 참고: 지금은 그대로 사용할 것

🔹 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 inferenceEmptyActionCreator로 되어서 에러 발생한 것으로 추정.

    원래 AsyncActionCreatorBuilder로 만들어진 typePayloadActionCreator여야 함. 우선 createAsyncAction에서 Request typeundefined로 되어 있는 걸 any로 바꾸니까 되서 임시 조치함.

🔹 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 의 Generics 순서

👉 <TReturnType, TState, TExtraThunkArg, TBasicAction>

  1. TReturnType

    thunk 함수의 반환 값의 타입을 설정

    • 참고

      • 아무것도 반환 하지 않는 경우, void 넣음

      • 현재 : Promise<void>가 더 정확

        (이유:thunk 함수에서 async 사용 중)

        (but, void 사용 문제 없음)

  2. TState

    스토어 상태에 대한 타입 설정

  3. TExtraThunkArg

    redux-thunk 미들웨어의 Extra Argument의 타입을 설정

  4. 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

  • 리듀서, 액션, 타입, thunk 함수를 불러와서 내보내기

코드

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>;

📌 프리젠테이셔널 컴포넌트 준비

🔹 GithubUsernameForm

◾ 제작 사항

  • 인풋 (사용자 계정명을 입력 할 수 있는)
  • 버튼 (클릭 시, 정보 조회)

◾ 컴포넌트

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;
}

🔹 GithubProfileInfo

◾ 제작 사항

사용자 계정에 대한 정보 보여주기

(이름, 프로필 사진, 자기소개, 블로그 링크(링크 있는 경우만 렌더링))

◾ 컴포넌트

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;
}

📌 컨테이너 준비

🔹 GithubProfileLoader

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;

참고

profile
복습 목적 블로그 입니다.

0개의 댓글