見出し画像

ReactでRedux + TypeScriptで型をしっかり付けて非同期処理してみる

小規模なプロジェクトでは、正直なところReduxをほとんど使用する機会はないと思います。
しかし、偶然にも今回はReduxを使う必要がありましたので、試してみました。
ただし、今回はFluxアーキテクチャやActionのDispatch、ReducerによるStateの更新などについては触れませんので、ご了承ください。
Reduxの概念については、他の方々が分かりやすく説明してくれている記事や公式ドキュメントを参考にしていただくか、公式ドキュメントをご覧ください。
さて、Redux初心者として、使用する前に気になったポイントは、大きく思想面と機能面の2つです。


1.Redux面倒だから必要無い派とRedux必要派

個人的な意見として、Reduxにおけるボイラーテンプレートは正直面倒だと思います。
ただし、フロントエンドにおけるStateの秩序が保たれるという点では、プロジェクトによってはむしろReduxを使うべきだと考えます。
私が読んだ「Reduxの利点を振り返る」というスライドは、Reduxの位置づけを一番しっくりと理解できるものであり、納得できました。
是非一度読んでみてください。
ただし、ReduxがなくてもContext APIやuseReducerなどを使ってチームやコードの秩序と平穏を保つことができる場合は、無理にReduxを使用する必要は全くないと思います。

2.Reduxでの非同期処理

Reduxの特性として、非同期処理を直接行うことができないため、Fluxサイクルの中で非同期処理を行う場合は、大きく以下の3つの方法に分類されると考えられます。

①Middlewareを導入する
②ReactのHooksを使用する
③Component内で非同期処理を行う

関心の分離の観点から、Componentの再利用性が低くなるという理由で、3つ目の方法は除外します。
したがって、1つ目と2つ目の方法から選ぶことになります。
結論として、今回はHooksを使用して非同期処理を行うことにしました。ケースバイケースですが、Redux-thunkやRedux-Saga、Redux-ObservableなどのMiddlewareの導入も検討しましたが、現在ではHooksを使用することで多くの機能を実現できるため、その方法を選びました。

● 処理の結果を待つ
● 処理の結果を基に更に非同期処理を行う
● その間の非同期処理に伴い色々な箇所のStateを更新する

複雑なステートの更新が必要なUX体験を求められない限り、Middlewareを導入する必要はそこまでないと考えています。

3.実際に書いてみる

①Actionsを作る
②Stateを作る
③Reducersを作る
④ReactとStoresを繋げる準備をする
⑤ReactとStoresを繋げる
⑥Componentからactionを発火して非同期処理を行う

今回作成したディレクトリ構造は次の通りです。

// ※処理に関係の無いディレクトリは省略しています

├── App.test.tsx
├── hooks
│   ├── useAuth.ts
│   └── useSelector.ts
├── index.tsx
├── pages
│   ├── App.tsx
│   ├── Home.tsx
│   ├── SignIn.tsx
└── stores
  ├── reducers.ts
  ├── auth
  │   ├── actions.ts
  │   ├── model.ts
  │   ├── reducers.ts
  │   └── types.ts
  └── ui
      ├── actions.ts
      ├── model.ts
      ├── reducers.ts
      └── types.ts

今回のReduxの処理は、以下のようになります。

● AWSのCognitoを用いてログイン
● ログインの非同期処理をHooksから行いActionのDispatchを発火
● ログイン情報をReduxのStateに入れて持ち回す

それでは、まず必要なライブラリをインストールしてみましょう。

$ yarn add -D typescript-fsa

これで、ActionCreatorを作成する準備が整いました。

4.Actionを作る

  • Action Creatorsの生成 / Actionsの型定義

// stores/auth/types.ts

export const AUTHENTICATED = 'AUTHENTICATED';

// stores/auth/actions.ts
import { actionCreatorFactory } from 'typescript-fsa'; // ActionCreatorを作る
import { ActionType } from 'typesafe-actions'; // Actionsの型を作る
import { AUTHENTICATED } from './types';
import { authStateType, IParams, IResult, IError } from '../state';
const actionCreator = actionCreatorFactory()
//--------------------------------
// Actions Creators
//--------------------------------
export const Actions = {
  authenticated: actionCreator.async<IParams, IResult, IError>(AUTHENTICATED)
}
//--------------------------------
// Actions Types
//--------------------------------
export type ActionsType = ActionType<typeof Actions>

TypeScriptを使用している場合は、Actionsの型定義を忘れずに行いましょう。
ActionCreatorの書き方は通常とは少し異なることに注意し、各ライブラリのドキュメントや使用例を参考にしながらActionsを作成していきます。

● actionCreator.async<IParams, IResult, IError>
actionCreatorFactoryを使用している中で3つのジェネリクスの型により非同期用のアクションを作ることが可能です。
この3つのジェネリクスは非同期処理の一連のフローである<非同期処理開始時, 非同期処理の完了時, 非同期処理の失敗時>のアクションを自動で生成してくれますので、このジェネリクスに各フロー時の型を入れ込むとreducerの処理やdispatchの処理の記述時にしっかりとTSによる型安全・型補完が効くようになります。

<IParams, IResult, IError>
↓
<started, done, failed>
↓
<非同期処理開始時, 非同期処理の完了時, 非同期処理の失敗時>

というような流れで進めていきます。

5.State(Model)を作る

  • Stateの型定義

// stores/auth/model.ts
import { CognitoUserSession } from 'amazon-cognito-identity-js';
export type authState = {
  params: IParams | null,
  result: IResult | null,
  error: IError | null
}

// authenticated.started
export type IParams = {
  username: string;
  password: string;
}

// authenticated.done
export type IResult = {
  auth: CognitoUserSession | null;
}

// authenticated.failed
export type IError = {
  code: string;
  name: string;
}

stateの型を定義します。
初期値はreducersと密接に関連しているため、reducers内に記述し、型情報をエクスポートしておきます。

6.Reducersを作る

  • initialStateの生成とReducersの生成

  • 各InitialStateの型定義とReducersの型定義

  • actionCreatorFactoryで生成されている<started, done, failed>の各アクション用のreducer定義

$ yarn add -D typescript-fsa-reducers

これにより、reducersに型を簡単に追加することができるようになります。

// stores/auth/reducer.ts

import { authState } from './model';
import { Actions } from './actions'
import { reducerWithInitialState } from 'typescript-fsa-reducers';

//------------------------------
// initialState
//------------------------------
export const initialState: authState = {
  params: null,
  result: null,
  error: null,
}

//------------------------------
// Reducers
//------------------------------
export const AuthReducers = reducerWithInitialState<authState>(initialState)
  /**
   * ユーザーの認証情報をステート管理する
   */
  .case(ACTIONS.AuthActions.authenticated.started, (state, payload) => {
    return {
      ...state, ...payload.params,
    }
  })
  .case(ACTIONS.AuthActions.authenticated.done, (state, payload) => {
    return {
      ...state, ...payload.result
    }
  })
  .case(ACTIONS.AuthActions.authenticated.failed, (state, error) => {
    return {
      ...state, ...error, 
    }
  })

今回はAWSのCognitoを導入しているため、ログイン後のユーザーの型が必要となります。

import { CognitoUser } from "amazon-cognito-identity-js";

Reducerも通常の書き方とは異なり、reducerWithInitialStateのGenericsを使用して型を指定する必要があります。
また、.caseメソッドを使用してActionTypeを繋げ、Reducerの処理を記述していきます。

7.ReactとStoresを繋げる準備をする

  • Reducersをcombineでひとつにまとめる

  • State全体の型定義

// stores/reducers.ts

import { combineReducers } from 'redux'
import { AuthReducers } from './auth/reducer'
import { UIReducers } from './ui/reducer'
import { uiState } from './ui/model'
import { authState } from './auth/model'

//------------------------------
// States
//------------------------------
export type RootState = {
  auth: authState
  ui: uiState // authと同じ要領で作る
}

//------------------------------
// Reducers
//------------------------------
export const reducers = combineReducers({
  auth: AuthReducers,
  ui: UIReducers, // authと同じ要領で作る
})

Stateの型は見落としやすいので、注意しましょう。
Selectorsを使用してStateの値を取得する際には、Stateの型が必要になります。

これまで、「Actionsの型」「Reducersの型」「Stateの型」としっかりと型定義してきましたので、TypeScriptでReduxを使用する準備が整いました。さて、いよいよStoresを生成し、Reactと結合していきましょう。

8.ReactとStoresを繋げる

// index.tsx

import * as serviceWorker from './serviceWorker';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './pages/App';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import { reducers } from './stores/reducers';
import { createLogger } from 'redux-logger';
import Amplify from 'aws-amplify';
import config from './aws-exports';
Amplify.configure(config)
const loggers = createLogger({ diff: true, collapsed: true })
const stores = createStore(reducers, applyMiddleware(loggers))

ReactDOM.render(
<React.StrictMode>
  <Provider store={stores}>
    <App />
  </Provider>
</React.StrictMode>,
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();

これにより、Component側からActionをDispatchすることができるようになりました。

9.Componentからactionを発火して非同期処理を行う

// SignIn.tsx

import React, { useState } from 'react';
import { TextField, Button, Container, Box, Typography } from '@material-ui/core';
import { Wrapper } from '../components/Wrapper';
import { Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';

//------------------------------
// Component
//------------------------------
const SignIn = () => {

//------------------------------
// Hooks
//------------------------------
  const [id, setId] = useState<string>('')
  const [password, setPassword] = useState<string>('')
  const auth = useAuth()

  return (
    <Wrapper>
      <Container maxWidth="sm">
        <Typography component={"h2"} variant={"h5"} align={"center"}>
          <Box fontWeight="fontWeightBold">SignIn - ログインする</Box>
        </Typography>
        <Box>
          <Typography component={"p"} variant={"body1"}>ID</Typography>
          <TextField value={id} onChange={(e) => setId(e.target.value)} />
          <Typography component={"p"} variant={"body1"}>PASSWORD</Typography>
          <TextField type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
        </Box>
        <Box>
          <Button color="primary" onClick={() => auth.signIn(id, password)}>
            ログイン
          </Button>
        </Box>
      </Container>
    </Wrapper>
  )
}

export default SignIn;
// hooks/useAuth.ts

import { useCallback } from 'react';
import { Auth } from 'aws-amplify';
import { CognitoUser } from "amazon-cognito-identity-js";
import { useDispatch } from 'react-redux';
import { useHistory } from "react-router";
import { Actions } from '../stores/auth/actions';

//------------------------------
// Type
//------------------------------
export type useAuthType = {
  signIn: (id: string, password: string) => Promise<void>
}

//------------------------------
// Hooks
//------------------------------
export const useAuth = (): useAuthType => {
  const dispatch = useDispatch()
  const history = useHistory()

  /**
   * サインイン処理
   * @param id string
   * @param password string
   */
  const signIn = async (username: string, password: string): Promise<void> => {
    // ※ActionCreatorsで.asyncで作成しているのでアクションの発行は「アクション名.started || アクション名.done || アクション名.failed」とすることができる。
    return await Auth.signIn(username, password).then((res) => {
      // 成功した場合.doneアクションをdispatchする
        dispatch(Actions.authenticated.done({
          result: res,
          error: null,
        }))
        history.push('/')
    }).catch((e) => {
      // エラーが有った場合.failedアクションをdispatchする
      dispatch(Actions.authenticated.failed({
        params: {
          username: username,
          password: password,
        }
        error: { ...e },
      }))
      alert('ログインに失敗しました')
      window.location.reload()
      throw e
    }
  }

  return { signIn }
}

CustomHooksとして独自に切り出したHooksのsignIn関数を使用してログイン処理(非同期処理)を行い、Dispatchを使用してActionCreatorを発火させています。
ログイン情報とログイン状態(boolean)をpayloadとして渡しています。

dispatch(AuthActions.authenticated.done({ auth: loginResult, error: null }))

このCustomHooksをComponent側から使用することで、ログイン処理を簡潔に記述することができます。
Component内でCustomHooksを呼び出し、返された関数を実行することでログインが行われ、Dispatchが発火します。
これにより、Component側から簡単にログイン処理を実行することができます。

// Hooksを初期化

const auth = useAuth()

<Box>
 <Typography component={"p"} variant={"body1"}>ID</Typography>
 <TextField value={id} onChange={(e) => setId(e.target.value)} />
 <Typography component={"p"} variant={"body1"}>PASSWORD</Typography>
 <TextField type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</Box>
<Box>
 // ログインボタンを押したときidとpasswordをHooksのsignIn関数に引数として渡して処理
 <Button color="primary" onClick={() => auth.signIn(id, password)}>
    ログイン
 </Button>
</Box>

ログイン情報を入力し、ログインボタンを押すと、エラーがなければログイン情報がReduxのStateに保存されます。
その後は自由にStateを取得し、必要に応じて使用することができます。ログイン情報はReduxのStateで管理されるため、他のコンポーネントや機能との連携も容易です。

import { useSelector } from 'react-redux';
import { RootState } from '../stores/reducers';

// 引数のstateにRootStateの型を宣言するのを忘れないように注意する
const selectors = useSelector((state: RootState) => state.auth)

その後、Stateの値を取得することができます。

10.最後に

ReduxをTypeScriptで使用する場合、型定義の手間が多くかかる面がありますが、それにもかかわらず、状態の管理において秩序を保つという点では非常に優れていると感じました。

また、ReduxではuseSelectorやuseDispatchといったHooksを利用することができるようになり、HOCを使わずに簡単にReduxを操作できるようになりました。
これにより、使いやすさが向上したと思います。是非参考になれば幸いです。

それでは。

この記事が気に入ったらサポートをしてみませんか?