はじめに
頻繁に生じる課題ではありませんが、状態をpropの変化に応じて変更したい時があります。
例えば、propで受け取ったデータから1つを選択させるようなコンポーネントを考えてください。
アイテムを選択する部分は下のように実装しました。
const ItemSelector: FC<{
items: readonly Item[];
}> = ({ items }) => {
const [selectedId, setSelectedId] = useState('');
const handleSelectChange: ChangeEventHandler<HTMLSelectElement> = (e) => {
const itemId = e.target.value;
setSelectedId(itemId);
};
return ...;
};
この実装ではitemsの変更によってselectedIdが変更されることはありません。
これでは、itemsから選択中のitemが削除された状態に陥ってしまいます。
このような状態になることを避けるため、チェックボックスの変更によって選択がリセットされるような方法を紹介します。
❌悪い例: useEffectを使用する
1つ目の解決方法はuseEffectを使用してitemsの変化の度にselectedIdを変更する方法です。
const ItemSelector: FC<{
items: readonly Item[];
}> = ({ items }) => {
const [selectedId, setSelectedId] = useState('');
const handleSelectChange: ChangeEventHandler<HTMLSelectElement> = (e) => {
const itemId = e.target.value;
setSelectedId(itemId);
};
useEffect(() => {
setSelectedId('');
}, [items]);
return ...;
};
この変更によって、正常に動作して見えますが、この記述は理想的でありません。
このコンポーネントはitemsが変更される度に再レンダーが行われDOMを更新した後に、useEffect内に記述したコードが実行されます。useEffect内ではselectedIdを更新しているので、もう一度selectedIdを更新した状態でコンポーネントが再レンダーされDOMの更新が行われます。
DOMの更新の繰り返しは見栄えが悪いですし、不要です。それが行われないような解決策を次に紹介します。
そもそもuseEffectはどのような時に利用するのでしょうか。useEffectはReactがReactの外の世界と同期するためにのみ利用されるべきです。
「Reactの外の世界と同期する」とは、window.localStorageのようなWeb APIやfetch等の外部のネットワークとの通信を利用してそれらの情報をReactに反映させることを指します。
それを意識すれば上記のようなコードは決して書かないはずです。
コンポーネントにkeyを付与する
2つ目の解決方法はコンポーネントにkeyを付与することです。
選択を行うコンポーネントであるItemSelectorに、条件に応じて変化するkeyをpropとして付与します。
<ItemSelector key={selectedKey} items={items} />
Reactは同じコンポーネントが同じ場所でレンダーされると状態を保持します。これがitemsを変更しても、ItemSelectorが同じselectedIdを持ち続ける理由です。
つまり、ItemSelectorが異なるselectedIdを持つようにするには、異なるコンポーネントか、異なる場所にレンダーさせる必要があります。
そして、同一のコンポーネントであっても異なるkeyを持ったコンポーネントは異なるコンポーネントとしてReactに伝えられます。
このような背景があり、itemsの変化に対応して変化するkeyをItemSelectorに渡しています。この方法ではselectedIdだけではなくItemSelectorの全ての状態が初期化されることに注意してください。
この解決方法は、ItemSelectorの内部の実装を加味して、外からkeyを付与必要がある点に問題が潜んでいます。
下のようにコンポーネントを間に挟むか(keyに渡す変数名が雑すぎるので真似しないほうが良いです)
const ItemSelector: FC<{
itemsId: string;
items: readonly Item[];
}> = ({ itemsId, ...props }) => {
return <_ItemSelector key={itemsId} {...props} />;
};
const _ItemSelector = ...
次に紹介する解決法を利用することでその問題を解決します(次に紹介する方法の方が優れているわけではないです)。
前のpropを記憶する
1つ前のレンダー時のitemsを状態として保持しておき、変化があったらselectedIdを初期化する方法です。
const ItemSelector: FC<{
items: readonly Item[];
}> = ({ items }) => {
const [selectedId, setSelectedId] = useState("");
const [prevItems, setPrevItems] = useState(items);
const handleSelectChange: ChangeEventHandler<HTMLSelectElement> = (e) => {
const itemId = e.target.value;
setSelectedId(itemId);
};
if (items !== prevItems) {
setSelectedId("");
setPrevItems(items);
}
return ...
};
prevItemsという状態を作成し、propであるitemsに変更がないかをレンダー毎に確認しています。そして、変更があればselectedIdの初期化とprevItemsの更新を行います(例では簡単のためitemsがメモ化されていることを前提としているコードです。比較が正常に行われていることは慎重に確認してくだs)。
この方法では、itemsの変更によってItemSelectorの再レンダーのタイミングで状態が更新され、さらにもう一度レンダリングされることになりますが、DOMの更新は一度で済むのでuseEffectに比べてコストが少ない更新になります(再レンダーはJavaScriptの計算だけなので大きな欠点とは考えていないです)。
このコードは理解し辛いので、2つ目の解決策を優先的に利用することが推奨されます。
しかし、汎用的なコンポーネントを作りたく、外部から強制的にkeyを付与させる仕様等を避けたい場合や、特定の状態のみを初期化したい場合にはこの方法が最良になります。
さいごに
状態をpropの変化に応じて更新する方法を紹介しました。1つ目の方法は避けて欲しいですが、2つ目と3つ目は一長一短があるので状況によって使い分けると良いです。
そして、このような状況に陥った時、今回紹介した方法へ書き直す前に考えて欲しいことがあります。それは「本当に状態にする必要があるか」です。propを用いて計算できないか、他の状態を組み合わせて計算できないかを考えて、必要以上に状態を作らないようにして下さい。