はじめに
頻繁に生じる課題ではありませんが、状態を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を用いて計算できないか、他の状態を組み合わせて計算できないかを考えて、必要以上に状態を作らないようにして下さい。