状態間の同期は一般的な話題ではあるのですが, 例として React の制御コンポーネントの実装を題材にします. ここで非対称な状態とは, 各状態のとり得る値の集合の間に一対一の対応がないことを指しています. というか一対一に対応するのであればふつうは状態を分けなくてよろしい.
まずはおさらいとして, 基本的な制御コンポーネントを見てみましょう (sandbox).
const MyTextInput: React.VFC<{ text: string; onChange: (text: string) => void; }> = ({ text, onChange }) => ( <input type="text" value={text} onChange={(e) => { onChange(e.target.value); }} /> );
この場合はユーザーが入力するデータと, このコンポーネント外部で状態として管理されるデータ (text
) がともに string
なので, 特に変わったことはありません.
ではユーザー入力と状態でデータの種類が異なるような場合はどうでしょうか.
ここでは例として, ユーザー入力が string
, 状態は Date
であるような時刻入力コンポーネントを考えてみます (sandbox).
function stringify(date: Date): string { try { return date.toISOString(); } catch { return ""; } } function parse(text: string): Date { if (text === "") { return new Date(NaN); } else { return new Date(text); } } const MyDateInput: React.VFC<{ date: Date; onChange: (date: Date) => void; }> = ({ date, onChange }) => ( <input type="text" value={stringify(date)} onChange={(e) => { onChange(parse(e.target.value)); }} /> );
これは動作確認してみるとすぐにわかりますが, まともにテキスト入力することができず, ほぼ役に立ちません. よくある実装ミスです.
この例の実装では, 状態として管理されているのは date: Date
のみです.
一方で,
- 不正な時刻を表す
Date
は 1 種類だが, 時刻の表現として不正なstring
は無数に存在する - 同じ時刻を表す
Date
は 1 種類だが, 同じ時刻を表すstring
は"2006-01-02T15:03:04.999Z"
と"2006-01-03T00:03:04.999+0900"
など, いくつも存在する
といったように, Date
と string
の値は一対一に対応せず, Date
の値の種類よりも string
の値の種類の方が多くなっています.
このため, 例えばユーザーの入力途中の時刻として不正な string
などは保持することができず, 入力に支障をきたしてしまいます.
ということで, 少なくとも date: Date
とは別に, ユーザー入力の string
も状態として管理する必要があります. 実際にやってみましょうs (sandbox).
const MyDateInput: React.VFC<{ date: Date; onChange: (date: Date) => void; }> = ({ date, onChange }) => { // string で入力データを保持するための状態 const [text, setText] = useState(() => stringify(date)); const textRef = useRef(text); useEffect(() => { if (text !== textRef.current) { // text が変更されたら通知 (date に反映) onChange(parse(text)); } textRef.current = text; }, [text, onChange]); return ( <input type="text" value={text} onChange={(e) => { setText(e.target.value); }} /> ); };
一見うまく動くように見えるのですが, これでは非制御コンポーネントとなってしまっているので, 当初の制御コンポーネントを作るという要件を満たしていません.
例えば date
が変更された場合, その変更はこのコンポーネントには反映されません.
よくある実装ミスその 2 です.
では date
が変更されたときに, コンポーネント内部の状態に対して反映が行われるようにしてみましょう (sandbox).
const MyDateInput: React.VFC<{ date: Date; onChange: (date: Date) => void; }> = ({ date, onChange }) => { // string で入力データを保持するための状態 const [text, setText] = useState(() => stringify(date)); const textRef = useRef(text); const dateRef = useRef(date); useEffect(() => { if (text !== textRef.current) { // text が変更されたら通知 (date に反映) onChange(parse(text)); } else if (date !== dateRef.current) { // date が変更されたら text に反映 setText(stringify(date)); } textRef.current = text; dateRef.current = date; }, [text, date, onChange]); return ( <input type="text" value={text} onChange={(e) => { setText(e.target.value); }} /> ); };
ところがこれでは最初と同じように, まともにテキスト入力できないようになってしまいました. 複雑になった分より悪くなったといえます.
ここで残っている問題は date
が変更されたときに, 無条件で text
を更新していることです.
先程見たように, Date
は string
に比べて値の種類の数が少ないので,
- ユーザー入力によって
text
が更新され,date
に反映 - これによって
date
が更新されたのでtext
に反映
と相互に反映を行う過程で, Date
で表現できない text
の違いが潰されて, 結果としてユーザー入力が破壊されるような結果となってしまうのです.
これを解消するためには, 2 での text
への反映に条件をつけて, すでに date
と text
が同じ時刻 (不正な場合を含む) を表しているような場合はスキップするようにしてやればよいです (sandbox).
function equal(a: Date, b: Date): boolean { return Object.is(a.getTime(), b.getTime()); } const MyDateInput: React.VFC<{ date: Date; onChange: (date: Date) => void; }> = ({ date, onChange }) => { // string で入力データを保持するための状態 const [text, setText] = useState(() => stringify(date)); const textRef = useRef(text); const dateRef = useRef(date); useEffect(() => { if (text !== textRef.current) { // text が変更されたら通知 (date に反映) onChange(parse(text)); } else if (date !== dateRef.current) { // date が変更されたら text に反映 // ただし text にすでに date が反映されているとみなせる場合は無視 if (!equal(date, parse(text))) { setText(stringify(date)); } } textRef.current = text; dateRef.current = date; }, [text, date, onChange]); return ( <input type="text" value={text} onChange={(e) => { setText(e.target.value); }} /> ); };
こうすることでユーザー入力は破壊されず, かつ制御コンポーネントとして適切な振る舞いをするようになりました. めでたしめでたし.
2021-11-06 追記 このパターンを簡単に実装するためのライブラリを作ったのでよろしければどうぞ. github.com
最初に書いたとおり状態間の同期は一般的な話題なので, もっと抽象的に議論することもできるはず. 私は自分が考えている問題にそのまま適用できるように一般化していたら話題が散らかってきたのと, 数式や図が書きづらい環境で厳密に書くのが面倒になってきたので今回はここまで. 先行研究はいくらでもありそうなので, もし参考になりそうな文献をご存知であれば教えて下さい.
あと注意したいのは, 状態を複数持って同期するのは大抵アンチパターンです (React に限らず, GUI アプリケーション全般においても). 必要かどうか十分考察した上で, 本当に必要な場面でのみ使いましょう.