【updater function編】Reactアンチパターン
開発環境
- react 18.2.0
前提
Reactの新しいドキュメントであるreact.devを眺めていて、何気なくアンチパターンの実装をしてしまっていることに気づいた今日この頃です。
基本的な内容にはなりますが、ゴリゴリReactを書いている人も一度目を通してもらえると嬉しいです。
本題
この記事で紹介するのはupdater functionとuseReducerについてです。
早速アンチパターンから見ていきます。
state更新のアンチパターン
以下の例ではstateは3回更新し、numberが3になるように見えますが、実際の値は1です。
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}
これは、Rect がイベント ハンドラー内のすべてのコードが実行されるまで待機してから、状態の更新を処理することに関係しています。
この動作はバッチ処理とも呼ばれ、 React アプリの実行を大幅に高速化します。
では、同一イベントハンドラ内で複数回stateを更新したいとき、どうすれば良いでしょうか?
ここで、updater functionを利用します。
以下が書き直したパターンです。
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
}}>+3</button>
</>
)
}
updater functionを利用することで、イベントハンドラ内であっても最新のstateを参照できます。
これらのケースのように、直前のstateを参照したい場合、アップデータ関数を利用して問題ないです。
ここまでは、本題ではありますが、ほとんどの方が理解されている(ことだと思う)ので、前置きです。
ここからはuseReducerについてです。
updater functionを利用するときのデメリット?ですが、コード自体の可読性は落ちることがあります。
よくあるケースとして、配列のstateに対して、データの追加、編集、削除などを行うときです。
例えば、以下のようなデータの一覧の行ごとにチェックボックスが付いているときのイベントハンドラの処理ですが、一見分かりにくいです。
onChange={(e) => {
if (checkedItems.map((item) => item.id).includes(e.target.value)) {
setCheckedItems((prev) => prev.filter((x) => x.id !== e.target.value)) // 削除
} else {
setCheckedItems((prev) => [...prev, { id, title }]) // 追加
}
}
やりたいこととして、checkedItemsという{ id: ‘hoge’, title: ‘foo’ }のようなオブジェクトを格納する配列に対して、チェックの有無に応じて追加、削除してます。
useReducerはupdater functionと同様にレンダリング中に実行されます。(アクションは次のレンダリングまでキューに入れられます)
ですので、useReducerを利用するとコードの可読性が上げつつ、置き換えることができます。
import { useReducer } from "react"
const hogeList = [
{ id: 1, title: 'hoge1' },
{ id: 2, title: 'hoge2' },
{ id: 3, title: 'hoge3' },
]
const reducer = (
checkedItems: { id: number; title: string }[],
action: { type: 'ADD' | 'DELETE'; id: number; title: string },
) => {
switch (action.type) {
case 'ADD':
return [...checkedItems, { id: action.id, title: action.title }]
case 'DELETE':
return checkedItems.filter((x) => x.id !== action.id)
default:
throw Error('Unknown action: ' + action.type)
}
}
export const HogeList = () => {
const [checkedItems, dispatch] = useReducer(reducer, [])
return (
<>
{hogeList.map((entry) => (
<input
key={entry.id}
value={entry.title}
onChange={(e) => {
if (checkItems.map((item) => item.id).includes(e.target.value)) {
return () => dispatch({ type: 'DELETE', ...entry })
} else {
return () => dispatch({ type: 'ADD', ...entry })
}
}}
/>
))}
</>
)
}
コードの記述量は増えましたが、チェックボックスがチェックされたとき’ADD’、チャックが外れたとき‘DELETE’ というように一見して何をやっているのかが分かり、ロジックを気にすることなく見れます。
上記の例ではコンポーネントの中でuseReducerを定義していますが、カスタムフックにして外に切り出せばよりロジックを気にすることはなくなります。
さいごに
公式ドキュメントにも記載ありますが、updater functionとuseReducerの使い分けは好みです。(もともこもない・・)
条件によってロジックが複数ある場合は、useReducerを検討してみるのはアリですね。