リファクタリングのお仕事をしてみたの続きです。今回は、少し面白かった問題に付いて書いてみました。
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)を待たないと行けなくなりますね。😅