Help us understand the problem. What is going on with this article?

Next.js × TypeScriptの同期・非同期処理をHooksを使って書く ~非同期処理編~

概要

こんにちは、よしデブです。

前回Next.js × TypeScriptの同期・非同期処理をHooksを使って書く ~同期的処理編~の続きです。

  1. Reduxを始めるの準備
  2. 同期処理でTodo追加・完了機能を作る
  3. 非同期処理でログイン機能を作る(今日はここ)
  4. (おまけ)その他のライブラリ紹介

やっと本シリーズのメインが書けます...!!
本記事では、Next.js × TypeScriptにおいて、非同期処理をRedux Hooksを使って書く方法を紹介します。
再三言ってきてますが、これから紹介する方法は私の思うベストプラクティス なので、もっと良い書き方があるよ!という方はコメントお待ちしておりますm(_ _)m

ログイン機能に関する状態管理を定義する

まず、ログイン機能に関するActionType、ActionCreator、Reducerを定義していきます。
私は非同期処理については原則
1. リクエスト開始状態
2. リクエスト成功状態
3. リクエスト失敗状態

の3状態を持つようにしています。リクエスト開始状態でローディング処理をし、リクエスト成功状態で成功結果を表示、リクエスト失敗状態で失敗結果を表示といった具合に切り分けています。

src/store/auth/types.ts
export default {
  FETCH_LOGIN: 'FETCH_LOGIN',
  FETCH_LOGIN_SUCCESS: 'FETCH_LOGIN_SUCCESS',
  FETCH_LOGIN_FAILURE: 'FETCH_LOGIN_FAILURE'
} as const
src/store/auth/actions.ts
import { User } from '@store/auth/index'
import types from './types'

export function requestLogin() {
  return {
    type: types.FETCH_LOGIN,
  }
}

export function successLogin(user: User) {
  return {
    type: types.FETCH_LOGIN_SUCCESS,
    payload: {
      user
    }
  }
}

export function failureLogin() {
  return {
    type: types.FETCH_LOGIN_FAILURE
  }
}
src/store/auth/index.ts
import { Actions } from '../actions'
import types from './types'

export interface User {
  name: string
}

interface State {
  isFetching: boolean,
  user?: User
}

export function initialState(injects?: State): State {
  return {
    user: undefined,
    isFetching: false,
    ...injects,
  }
}

export function reducer(state = initialState(), action: Actions): State {
  switch (action.type) {
    // リクエストスタート 通信中の状態にする(isFetching=true)
    case types.FETCH_LOGIN:
      return { ...state, isFetching: true }
    // リクエスト成功 通信終了(isFetching=false)にし、取得したユーザ情報を保存する
    case types.FETCH_LOGIN_SUCCESS:
      return { ...state, isFetching: false, user: action.payload.user }
    // リクエスト失敗 通信終了(isFetching=false)にすること以外今回は何もしない
    case types.FETCH_LOGIN_FAILURE:
      return { ...state, isFetching: false }
    default:
      return state
  }
}

Actions型、RootReducer、RootStateの変更

authという新たな状態が作成されたのでsrc/store/actions.tsを以下のように変更します。
このように状態が増えるたびに CreatorsToActionsを追加するだけで簡単に型推論ができちゃいます。

src/store/actions.ts
type Unbox<T> = T extends { [K in keyof T]: infer U } ? U : never
type ReturnTypes<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any
  ? ReturnType<T[K]>
  : never
}
type CreatorsToActions<T> = Unbox<ReturnTypes<T>>

export type Actions = CreatorsToActions<typeof import('./todos/actions')>
                    | CreatorsToActions<typeof import('./auth/actions')> // 追加

/** Actionsの推論結果
type Actions = {
  type: 'ADD_TODO'
  payload: {
    id: string,
    done: boolean,
    task: string,
  }
} | {
  type: 'DONE_TODO'
  payload: {
    id: string
  }
} | {
  type: 'FETCH_LOGIN'
} | {
  type: 'FETCH_LOGIN_SUCCESS'
  payload: {
    user: User
  }
} | {
  type: 'FETCH_LOGIN_FAILURE'
}
*/

非同期処理用のActionCreatorを作る

redux-thunkを使った非同期処理用のActionCreatorは以下のような書き方を行います。
ポイントはリクエスト開始を知らせるdispatchを呼び出した後に、リクエスト成功または失敗をdispatchするようなPromiseを返す関数」を返すようにします。
ややこしいですが、非同期用のActionCreatorは通常のActionCreatorと違って関数を返しています。

apiは、axoisのインスタンスです。src/common/api.ts でbaseURLなどのAPIを叩く際に必要な共通の設定をしています。
今回は紹介しませんが、 JWT認証等をする時は私はここでheaderにトークンを入れるようにしてます。

src/store/auth/asyncActions.ts
import { LoginFormValues } from '@components/organisms/LoginForm/LoginForm'
import { Action, Dispatch } from 'redux'
import { failureLogin, requestLogin, successLogin } from '@store/auth/actions'
import api from '@common/api'

export function login(values: LoginFormValues) {
  return async (dispatch: Dispatch<Action>) => {
    // リクエストスタート(リクエスト開始状態にする)
    dispatch(requestLogin());
    return api({
      method: "post",
      url: '/api/login',
      data: {
        'login_id': values.login_id,
        'password': values.password
      }
    }).then((response) => {
      // リクエスト成功(アクセストークンをローカルに保存)
      localStorage.setItem('jwt', response.data.access_token)
      // リクエスト成功状態にして、ユーザ情報を渡す
      dispatch(successLogin(response.data.user))
    }).catch((response) => {
      // リクエスト失敗
      dispatch(failureLogin())
    })
  };
}
src/common/api.ts
import axios, {Method} from 'axios'

const api = axios.create({
  baseURL: process.env.API_URL,
  xsrfHeaderName: 'X-CSRF-Token',
  withCredentials: true,
  headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
  responseType: 'json'
})

export default api;

ログインフォームを作る

前回作成したTodo入力フォームと同様にユーザIDとパスワードを入力するためのログインフォームを作成します。
前回と違う点はログインボタンを押した時に非同期処理用のActionCreator(login)をdispatchしている事だけです。
しかもその書き方は 普通のActionCreatorをdispatchする方法と同じなので、直感的に理解しやすいかと思います。

また、通信状態を表すisFetchingを使って、通信中ならローディングを表示することも簡単に実現することができます。

src/components/organisms/LoginForm/LoginForm.tsx
import ErrorText from '@components/atoms/forms/ErrorText'
import TextField from '@components/molecules/TextField/TextField'
import Button from '@material-ui/core/Button'
import CircleProgress from '@material-ui/core/CircularProgress'
import {login} from '@store/auth/asyncActions'
import { StoreState } from '@store/index'
import {
  Field,
  FieldProps,
  Form,
  Formik,
} from 'formik'
import React, {FC} from 'react'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import * as Yup from 'yup';


const FieldWrapper = styled.div`
  margin-top: 30px;
  margin-left: 40px;
`

const LoginButtonWrapper = styled.div`
  margin-top: 40px;
  margin-left: 40px;
`

const LoginSchema = Yup.object().shape({
  login_id: Yup.string()
    .required('入力してください'),
  password: Yup.string()
    .required('入力してください'),
});

export interface LoginFormValues {
  login_id: string;
  password: string;
}

const LoginForm: FC = ({}) => {
  const auth = useSelector((state: StoreState) => state.auth);

  const dispatch = useDispatch();
  const initialValues: LoginFormValues = { login_id: '', password: '' };

  // ログインボタンが押されたらlogin ActionCreatorをdispatchする
  const handleSubmit = (values: LoginFormValues) => {
    dispatch(login(values));
  }

  return(
    <Formik
      initialValues={initialValues}
      validationSchema={LoginSchema}
      onSubmit={handleSubmit}
      render={({errors, touched}) => (
        <Form>
          <Field
            name="login_id"
            render={(props: FieldProps) => {
              return (
                <FieldWrapper>
                  <TextField
                    label={'ID'}
                    type={'text'}
                    fieldProps={props}
                  />
                  {errors.login_id && touched.login_id &&
                  <ErrorText>
                    {errors.login_id}
                  </ErrorText>}
                </FieldWrapper>
              )
            }}
          />
          <Field
            name="password"
            render={(props: FieldProps) => {
              return (
                <FieldWrapper>
                  <TextField
                    label={'パスワード'}
                    type={'password'}
                    fieldProps={props}
                  />
                  {errors.password && touched.password &&
                  <ErrorText>
                    {errors.password}
                  </ErrorText>}
                </FieldWrapper>
              )
            }}
          />
          <LoginButtonWrapper>
            <Button type="submit" variant="contained" color="primary" disabled={auth.isFetching}>ログイン</Button>
            // 通信中ならローディングを表示する
            {auth.isFetching && <CircleProgress/>}
          </LoginButtonWrapper>
        </Form>
      )}
    />)
};

export default LoginForm;

ページを編集する

最後に、ログインフォームとユーザ情報を表示するようにページを編集します。
また、useSelector を用いてユーザ情報も取得するように編集して完成です。

pages/index.tsx
import LoginForm from '@components/organisms/LoginForm/LoginForm'
import TodoForm from '@components/organisms/TodoForm/TodoForm'
import { Button } from '@material-ui/core'
import Container from '@material-ui/core/Container'
import { StoreState } from '@store/index'
import { doneTodo } from '@store/todos/actions'
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'

/**
 * TopPage
 */
const TopPage = () => {
  const dispatch = useDispatch()
  // ユーザ情報も取得するように編集
  const [todos, user] = useSelector((state: StoreState) => [
    state.todos.todos,
    state.auth.user
  ])

  return (
    <main>
      <Container maxWidth="xs">
      <h1>Hello, World</h1>
      <h2>Todos</h2>
      <ul>
        {todos.map((todo, idx) => (
          <li key={idx}>
            <span
              style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
            >
              {todo.task}
            </span>
            <Button
              variant="contained"
              color="primary"
              disabled={todo.done}
              onClick={() => dispatch(doneTodo(todo.id))}
              style={{ marginLeft: 10 }}
            >
              DONE
            </Button>
          </li>
        ))}
      </ul>
      <TodoForm />
      // ここから追加
      <h2>Login</h2>
      <div>
        // ユーザ情報がある場合(ログインした場合)、ユーザ名を表示する
        {user ? 'こんにちは!' + user.name + 'さん' : 'ログインしてください'}
      </div>
      <LoginForm />
      // ここまで追加
      </Container>
    </main>
  )
}

export default TopPage

完成!!

demo3.gif

終わりに

Next.js × TypeScriptにおけるReduxの非同期処理をHooksで書く方法を紹介しました。
Hooksの登場で可読性がとても向上したように思います。また、TypeScriptによって型を意識したコーディングが可能になって、補完が効くようになって大変便利になりました。

本記事がよかったらLGTMお願いします。ありがとうございました!!
次回は本シリーズで紹介できなかったライブラリをおまけで紹介できたらと思います。

前回の記事たち

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away