EY-Office ブログ

リファクタリングのお仕事をしてみた(2)

リファクタリングのお仕事をしてみたの続きです。今回は、少し面白かった問題に付いて書いてみました。

リファクタリング Refactoring (Addison-Wesley Signature Series)

API通信部分をReduxに移動

今回のリファクタリング対象アプリでは、以下のような感じでバックエンド通信部分がReactコンポーネント内に書かれていました。しかも他の場所ではreduxを使っているのに、ここではuseStateを使っていました。😅

import React, { useState } from 'react'

export const App: React.FC = () => {
  type UserListType = {name: string, email: string}[]

  const [users, setUsers] = useState<UserListType>([])

  const createUser = async () => {
    const res = await fetch('/create', {method: 'POST'})
    const users: UserListType = await res.json()
    setUsers(users)
  }

  return (
    <div>
      <button onClick={() => createUser()}>登録</button>
      <ul>
        {users.map((user, ix) => <li key={ix}> {user.name} </li>)}
      </ul>
    </div>
  )
}

バックエンド通信部分は、表示を行うReactコンポーネント内にあるのは良くない事です。通信用モジュールを作りそこに移動するなどし、Reactコンポーネントからバックエンド通信部分は追い出すべきです。

このアプリではRedux Toolkitを使っているので、バックエンド通信部分はReduxに移動するのが良さそうです。以前のReduxでは、redux-thunkを使い通信結果を格納するreducerを呼び出す(dispatch)通信関数を書く必要がありました。

しかし、Redux Toolkit v1.3.0(2020年4月リリース)からは、これを定型的に書けるcreateAsyncThunkが実装されましたので、今回はこれを使いました。

Reactコンポーネント

Redux Toolkitを使ったReactコンポーネントの定型で、stateの利用はuseSelectorで、stateを変更するようなアクションの実行はuseDispatchを使うだけです。シンプルですね。😊

import React from 'react'
import { useDispatch, useSelector } from 'react-redux';
import { RootState, userCreate } from './store';

export const App: React.FC = () => {

  const users = useSelector((state: RootState) => state.users);
  const dispatch = useDispatch();

  const createUser = () => {
    dispatch(userCreate())
  }

  return (
    <div>
      <button onClick={() => createUser()}>登録</button>
      <ul>
        {users.map((user, ix) => <li key={ix}> {user.name} </li>)}
      </ul>
    </div>
  )
}

Redux

  • ① Reactコンポーネントから呼び出される関数(action)をcreateAsyncThunkを使って定義します。redux-thunkを使った場合に比べ通信のコードのみでシンプルですね。
  • ② createAsyncThunkに対応したreducerはextraReducersに書きます、書き方も通常のreducerとは違いますが、実際のstate更新部分は通常のreducerと同じように書けます。
  • extraReducersに書くreducerはaction関数名.pendingなどのようにルールが決まっています
    • 関数名.pending : 通信(非同期処理)が始まる時に呼び出されるreducer、よく行われるのはLoading...表示用stateのONなどですね
    • 関数名.fulfilled : 通信(非同期処理)が成功した時に呼び出されるreducer、action.payloadにはaction関数の戻り値が入っています
    • 関数名.rejected : 通信(非同期処理)が失敗した時に呼び出されるreducer,action.payloadにはエラー情報が入っています
import { configureStore, createAsyncThunk, createSlice } from '@reduxjs/toolkit'

export type UserListType = {name: string, email: string}[]

type UserState = {
  users: UserListType
}
const initialState: UserState = {
  users: []
}

const slice = createSlice({
  name: "users",
  initialState,
  reducers: {
  },
  extraReducers: (builder) => {  // ← ②
    builder.addCase(userCreate.pending, (state, _action) => {
      state.users = []
    });
    builder.addCase(userCreate.fulfilled, (state, action) => {
      state.users = action.payload
    });
    builder.addCase(userCreate.rejected, (_state, action) => {
      console.log('Error :', action.payload)
    });
  }
})

export const userCreate = createAsyncThunk<UserListType, void>( // ← ①
  "users/create",
  async (_args, { rejectWithValue }) => {
    try {
      const res = await fetch('/create', {method: 'POST'})
      const userList: UserListType = await res.json()
      return userList
    } catch (error: any) {
      return rejectWithValue(error);
    }
  }
)

const store = configureStore({
  reducer: slice.reducer
})
export default store

export type RootState = ReturnType<typeof store.getState>

confirmダイアログがあるぞ!

ここからが本題です、実はリファクタリング対象コードは登録前にチェック用バックエンド通信(/check)を行います。

  • チェック結果に問題がない場合は、登録バックエンド通信(/create)が呼びだされます
  • チェック結果に問題がある場合は、 confirm(window.confirm)で確認し、OKが押されたときは登録バックエンド通信(/create)が呼びだされます

confirm()は非同期処理を基本とするJavaScriptでは珍しく同期処理を行います。 confirm()ダイアログが表示され、OK/キャンセルが押されるまではconfirm()から戻ってこないので、以下のように簡単なコードになります。

import React, { useState } from 'react'

export const App: React.FC = () => {
  type CheckListType = {name: string, error: string}[]
  type UserListType = {name: string, email: string}[]

  const [users, setUsers] = useState<UserListType>([])

  const createUser = async () => {
    const res = await fetch('/check', {method: 'POST'})
    const checkList: CheckListType = await res.json()

    let ok = true
    if (checkList.find(e => e.error)) {
      ok = window.confirm("問題がありますが登録しますか?")
    }

    if (ok) {
      const res = await fetch('/create', {method: 'POST'})
      const users: UserListType = await res.json()
      setUsers(users)
    }
  }

  return (
    <div>
      <button onClick={() => createUser()}>登録</button>
      <ul>
        {users.map((user, ix) => <li key={ix}> {user.name} </li>)}
      </ul>
    </div>
  )
}

さてリファクタリングですが、Reduxを使うとactionは非同期になるのでconfirm()は使いにくくなります。また、リファクタリング対象の本物のアプリはMUI(Material-UI)を使っているので最終的にはconfirm()ではなくMUIのAlertsを使った方が良いと思います。

そこで、チェック済み(isChecked)と問題あり(isErrorExist)をstateで持つ事にしました(このようにページの状態を持つ方式はいくつかの方式があり、より良い方法もあるかも知れません)。

Reactコンポーネント

  • ① 登録ボタンが押された際には、チェック用actionが起動されます
  • ② チェックを行い、問題があった場合は確認用ボタンを表示しています(本番のコードではMUIのAlertsが表示されます)
    • OKの場合は、問題が無かった事にします
    • キャンセルの場合は、チェックをしなかった事にします
  • ③ チェック済みで、かつ問題がない場合は登録actionを起動します
    • この処理はuseEffectの中になくても動きますが、何らかの原因で再レンダリングが起こり2度実行されるのを防ぐ為にuseEffectの中にいれました
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux';
import { RootState, resetCheck, resetError, userCheck, userCreate } from './store';

export const App: React.FC = () => {

  const users = useSelector((state: RootState) => state.users);
  const isChecked = useSelector((state: RootState) => state.isChecked);
  const isErrorExist = useSelector((state: RootState) => state.isErrorExist);
  const dispatch = useDispatch();

  const createUser = () => {
    dispatch(userCheck())   // ← ①
  }

  useEffect(() => {   // ← ③
    if (isChecked && !isErrorExist) {
      dispatch(userCreate())
    }
  }, [isChecked, isErrorExist, dispatch])

  return (
    <div>
      <button onClick={() => createUser()}>登録</button>
      {isErrorExist &&             // ← ②
        <div>
          問題がありますが登録しますか?
          <button onClick={() => dispatch(resetCheck())}>キャンセル</button>
          <button onClick={() => dispatch(resetError())}>OK</button>
        </div>
      }
      <ul>
        {users.map((user, ix) => <li key={ix}> {user.name} </li>)}
      </ul>
    </div>
  )
}

Redux

  • ① チェック用バックエンド通信userCheckのactionをcreateAsyncThunkで定義
  • ② チェック済み(isChecked)と、問題あり(isErrorExist) stateを定義
  • ③ userCheck用のextraReducersを定義
    • チェック済み、問題ありstateのON/OFF
  • ④ チェックをしなかった事にする、問題が無かった事にするaction/reducerの定義
import { configureStore, createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'

export type UserListType = {name: string, email: string}[]
export type CheckListType = {name: string, error: string}[]

type UserState = {
  users: UserListType
  isChecked: boolean     // ← ②
  isErrorExist: boolean
}
const initialState: UserState = {
  users: [],
  isChecked: false,
  isErrorExist: false
}

const slice = createSlice({
  name: "users",
  initialState,
  reducers: {
    resetCheck: (state, _action: PayloadAction<void>) => {  // ← ④
      state.isChecked = false
      state.isErrorExist = false
    },
    resetError: (state, _action: PayloadAction<void>) => {
      state.isErrorExist = false
    }
  },
  extraReducers: (builder) => {
    builder.addCase(userCheck.pending, (state, _action) => {
      state.isChecked = false
      state.isErrorExist = false
    });
    builder.addCase(userCheck.fulfilled, (state, action) => {
      state.isChecked = true
      state.isErrorExist = Boolean(action.payload.find(e => e.error))
    });
    builder.addCase(userCheck.rejected, (state, action) => {
      state.isChecked = true
      console.log('Error :', action.payload)
    });

    builder.addCase(userCreate.pending, (state, _action) => {  // ← ③
      state.isErrorExist = false
      state.isChecked = false
    });
    builder.addCase(userCreate.fulfilled, (state, action) => {
      state.users = action.payload
    });
    builder.addCase(userCreate.rejected, (state, action) => {
      console.log('Error :', action.payload)
    });
  }
})

export const userCheck = createAsyncThunk<CheckListType, void>(  // ← ①
  "users/check",
  async (_args, { rejectWithValue }) => {
    try {
      const res = await fetch('/check', {method: 'POST'})
      const checkList: CheckListType = await res.json()
      return checkList
    } catch (error: any) {
      return rejectWithValue(error);
    }
  }
)

export const userCreate = createAsyncThunk<UserListType, void>(
  "users/create",
  async (_args, { rejectWithValue }) => {
    try {
      const res = await fetch('/create', {method: 'POST'})
      const userList: UserListType = await res.json()
      return userList
    } catch (error: any) {
      return rejectWithValue(error);
    }
  }
)


const store = configureStore({
  reducer: slice.reducer
})

export default store

export type RootState = ReturnType<typeof store.getState>
export const { resetCheck, resetError } = slice.actions;

まとめ

繰り返しになりますが、confirm()はJavaScriptでは珍しく同期処理を行ってくれるので、余計な状態(state)を持たずに簡単にワークフローが作れます。
しかし、非同期処理を基本とするJavaScriptの中では異質な存在で、UIやいろいろなJavaScriptのライブラリーとは相性が悪く、いずれ状態(state)を待たないと行けなくなりますね。😅

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ