最近、React Flight Rulesをまとめている中で、ビジネス要件で時間選択器に出会い、ケーススタディに適していると思いました。
初版の時間選択器の実装#
選択器の UI は以下の通りです:
この選択器は 2 つの部分に分かれています:
- 第一部分はデフォルトの一組の時間ボタンで、クリックすると選択器は直接対応する値に設定されます。
- 第二部分は入力ボックスで、ユーザーがカスタムの時間を入力できます。
機能は比較的シンプルに見えますので、初版の選択器を実装してみましょう:
import { type Dayjs } from 'dayjs'
const FORMAT = 'HH:mm'
const defaultOptions = [
dayjs('08:00', FORMAT),
dayjs('10:00', FORMAT),
dayjs('16:00', FORMAT),
]
const MyTimeRadio: React.FC<{
value: Dayjs;
onChange: (value: Dayjs) => void;
}> = ({ value, onChange }) => {
const [timeInputValue, timeInputValue] = useState<Dayjs>()
const isCustomTime = !defaultOptions.includes(value)
return (
<div>
{defaultOptions.map((option) => (
<button
key={option.format(FORMAT)}
onClick={() => onChange(option)}
>
{option.format(FORMAT)}
</button>
))}
<div>
<span>カスタム時間</span>
<TimeInput
value={timePickerValue}
onChange={(time) => {
setTimePickerValue(time)
onChange(time)
}}
/>
</div>
</div>
)
}
ここでは、ユーザーが入力した時間を保存するために timeInputValue
の状態を追加しました。なぜ直接 value を使用しないのでしょうか?それは、value がコンポーネント全体で共有される prop であり、ユーザーが 10 時のボタンをクリックすると、value は 10 時に変わります。もし TimeInput コンポーネントが直接 value を使用すると、入力ボックスの値も 10 時になってしまい、これは明らかに望ましい結果ではありません。
そのため、ユーザーが入力した値を保存するために timeInputValue
を使用し、ボタンをクリックしても timeInputValue
は変わらないようにしました。これにより、入力ボックスの値が上書きされることを防げます。
見た目は良さそうで、シンプルですが、このコンポーネントにはエコーの問題があります。
仮にこのコンポーネントをフォームの中に置いた場合、フォームの初期データは API から取得する必要があります。コードは以下の通りです:
const MyForm: React.FC = () => {
const [time, setTime] = useState<Dayjs>()
useEffect(() => {
fetch('/api/time').then((res) => {
// もし res.time が 12:00 だった場合
setTime(res.time)
})
}, [])
return (
<form>
<MyTimeRadio value={time} onChange={setTime} />
</form>
)
}
MyTimeRadio が最初にレンダリングされるとき、取得される value は undefined です。API から返された時間を取得した後、value は 12:00 に変わります。この時、MyTimeRadio は再レンダリングされますが、timeInputValue
は依然として undefined です。
そのため、私たちのコンポーネントはカスタム時間を正常にエコーできず、少し改造が必要です。
完全機能第一版#
この問題に気づいた後、私の最初の反応は、value を正しく timeInputValue
に同期させたいので、リスナーを追加すればいいのではないかということでした。
// MyTimeRadio.tsx
const [timeInputValue, timeInputValue] = useState<Dayjs>()
useEffect(() => {
setTimeInputValue(value)
}, [value])
// ...
<TimeInput
value={timeInputValue}
/>
うん、見た目は良さそうですが、timeInputValue
を常に value
に合わせることはできません。なぜなら、ユーザーが 10 時のボタンをクリックして onChange をトリガーすると、最終的に value が変更されるため、timeInputValue
が上書きされることは望ましくありません。したがって、もう少しフィルタリングが必要です。
// MyTimeRadio.tsx
const [timeInputValue, timeInputValue] = useState<Dayjs>()
useEffect(() => {
if (isCustomTime) setTimeInputValue(value)
}, [value, isCustomTime])
// ...
<TimeInput
value={timeInputValue}
/>
完璧です。value の変化を監視し、値を timeInputValue
に同期させました。そして、ユーザーが入力した値が上書きされないように、isCustomTime
の判断を追加しました。
コンポーネントをテストして、エコーのバグが解決されました。
しかし、待ってください。私たちは React Flight Rules で明確に、useEffect の役割はレンダリング後のコールバックフックを提供することではなく、関数コンポーネント内でライフサイクルを実現することではないと述べました。その名前からもわかるように、彼の登場は副作用を解決するためです。私たちの useEffect コードの中で、value
と isCustomTime
はすべて React の内部状態であるため、ここで useEffect を使用することはその設計意図に合致しません。
では、useEffect を使用せずにこの問題を解決するにはどうすればよいのでしょうか?
完全機能第二版#
もし useRef を使用して timeInputValue の state を置き換えるなら、私たちは任意の場所でその値を変更できるので、useEffect は必要ありません。
// MyTimeRadio.tsx
const timeInputValueBackRef = useRef<Dayjs>()
if (isCustomTime) timeInputValueBackRef.current = value
const timeInputValue = isCustomTime ? value : timeInputValueBackRef.current
// ...
<TimeInput
value={timeInputValue}
/>
コードを実行してみると、機能は正常ですが、また別の問題が発生しました。私たちの飛行ルールでは、==React のレンダリングコードは純粋な関数であるべきだ == と言っていますが、レンダリングロジックに ref の読み取りと変更を追加すると、それはもはや純粋な関数ではなくなります。
なぜそう言えるのでしょうか?ref は毎回コンポーネントがレンダリングされるときに共用される値であり、関数の外部変数に相当します。そして、私たちのレンダリングロジックは純粋な関数であり、外部変数に依存(読み取り)すべきではなく、変更することもできません。
簡単な例を挙げてみましょう:
const add = (a: number) => ++a
add(1) // 2
add(1) // 2
add(1) // 2
コードが何度実行されても、add(1)
の戻り値は固定されています。これが純粋関数の特性です。
しかし、もし私たちが add 関数を次のように変更したら:
let a = 1
const add = () => ++a
add(1) // 2
add(1) // 3
add(1) // 4
a が外部変数であるため、add(1)
の戻り値はもはや固定されず、これでは純粋関数ではありません。
では、問題に戻り、レンダリング段階で ref を取り除くにはどうすればよいのでしょうか?
完全機能第三版#
上記のコードを振り返ると、useEffect の使用を避けるために timeInputValue
の値を ref に置き、props.value の変化によって再レンダリングが引き起こされる特性を利用して、value の値を timeInputValue
に割り当てました。
しかし、私たちの本来の目的は TimeInput コンポーネントの値が value に上書きされる問題を解決することです。したがって、この点に戻ると、私たちの ref は TimeInput コンポーネントの値のコピーを保存するために使用されるべきであり、TimeInput の onChange で値を割り当てる方が理にかなっています。
// MyTimeRadio.tsx
const timeInputValueBackRef = useRef<Dayjs>()
const isCustomTime = !defaultOptions.includes(value)
const timeInputValue = isCustomTime ? value : timeInputValueBackRef.current
// ...
<TimeInput
value={timeInputValue}
onChange={(time) => {
timeInputValueBackRef.current = time
onChange(time)
}}
/>
これで、私たちの ref は TimeInput の onChange の中だけで変更されることになり、つまり ref という外部変数の変更がレンダリングロジックの外に移動しました。これにより、レンダリングの純度が向上しました。
しかし、レンダリングは依然として ref を読み取っており、依然として非純粋な関数です。
この段階に至って、useEffect 以外に ref の読み取りと変更をレンダリングの外に移動させる方法を思いつくことはできませんでした。今日、再度 react の useState と useRef のドキュメントを見返して、新しいアイデアを得ました。
完全機能第四版#
const [timePickerValue, setTimePickerValue] = useState<Dayjs>()
if (isCustomTime && timePickerValue !== value) setTimePickerValue(value)
// ...
<TimeInput
value={timeInputValue}
/>
私たちの初版のコードに一行追加するだけで済みます: if (isCustomTime && timePickerValue !== value) setTimePickerValue(value)
。
なぜ私は useRef を放棄して useState に戻ったのでしょうか?
それは、useRef のドキュメントで React が明確に == レンダリング中に ref の値を読み取ったり変更したりしないでください == と言っているからです。もしレンダリング中に読み取ったり変更したりする必要がある場合は、state を代わりに使用してください。
以前、私は ref の理解をクラスコンポーネントのインスタンス属性に当てはめ、複数回のレンダリングで共用される変数として使用していたため、当然のようにそれをレンダリングロジックに置いてしまい、結果として第二版と第三版が生まれました。
ドキュメントでは、ref に適したシナリオとして以下の 3 つを推奨しています:
- タイムアウト ID を保存する
- DOM ノードを保存する
- 変動値を保存するが、それらは JSX の計算に参加する必要がない、つまり生成される JSX に影響を与えない
多くのドキュメントを読むことは有益です。コーディングの際には多くの思考が必要で、既存の知識をそのまま適用することはできません。関数型コンポーネントとクラスコンポーネントのメンタルモデルは異なるため、それらの違いを理解することが、より良いコードを書くために必要です。
第四版のコードに戻ると、なぜ最初からこの解決策を考えなかったのか?それは、レンダリング中に setState を使用することを無意識に拒否していたからです。なぜなら、そうすると無限ループが発生するからです(この例では、if を使用してこの状況を回避しました)。
React のドキュメントもそう述べています。このような行動は避けるべきであり、ほとんどの場合、イベントコールバックの中で state を変更するべきです。レンダリングに適応するために state を変更する必要があるのは、少数のケースだけです(つまり、私たちが直面している状況です)。
では、これを行うことの利点は何でしょうか?第一版の useEffect を使用することと比較して:
- React のメンタルモデルにより適合する
- パフォーマンスが向上する
useEffect を使用する場合、コンポーネントは次のようになります:
なぜなら、useEffect のトリガータイミングはレンダリング完了後であるため、コンポーネントは完全なレンダリングを 2 回経過し、子コンポーネントも一緒に実行されます。
一方、setState を使用する場合、コンポーネントは次のようになります:
レンダリング 1 では、私は灰色の背景を使用しました。これは、コンポーネントが JSX を返す段階に達すると、すぐに再レンダリングがトリガーされ、子コンポーネントの実行をスキップするからです。
まとめ#
この記事では、useState、useRef、useEffect の 3 つのフックの使用シナリオについて議論し、実際の問題を解決するためにいくつかの解決策を試みました。
重要なポイントをまとめると:
- useEffect の名前は effect であり、その役割は副作用を処理し、React を外部世界と同期させることです。
- レンダリングが純粋な関数であることを保証する必要があります。
- レンダリングロジックの中で ref の値を読み取ったり変更したりすべきではなく、state を代わりに使用すべきです。
- 過去の知識をそのまま適用することはできず、React の関数型コンポーネントのメンタルモデルを理解する必要があります。Thinking in React。