【React】状態をpropの変化に応じて更新する

## はじめに
頻繁に生じる課題ではありませんが、状態をpropの変化に応じて変更したい時があります。
例えば、propで受け取ったデータから1つを選択させるようなコンポーネントを考えてください。

https://codesandbox.io/embed/cjwhcx?view=preview

アイテムを選択する部分は下のように実装しました。
```tsx
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`を変更する方法です。
```tsx
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 ...;
};
```

https://codesandbox.io/embed/m27v6m?view=preview

この変更によって、正常に動作して見えますが、この記述は理想的でありません。

このコンポーネントは`items`が変更される度に再レンダーが行われDOMを更新した後に、`useEffect`内に記述したコードが実行されます。`useEffect`内では`selectedId`を更新しているので、もう一度`selectedId`を更新した状態でコンポーネントが再レンダーされDOMの更新が行われます。

DOMの更新の繰り返しは見栄えが悪いですし、不要です。それが行われないような解決策を次に紹介します。

:::note
そもそも`useEffect`はどのような時に利用するのでしょうか。`useEffect`はReactがReactの外の世界と同期するためにのみ利用されるべきです。
「Reactの外の世界と同期する」とは、`window.localStorage`のようなWeb APIや`fetch`等の外部のネットワークとの通信を利用してそれらの情報をReactに反映させることを指します。

それを意識すれば上記のようなコードは決して書かないはずです。
:::

## コンポーネントにkeyを付与する
2つ目の解決方法はコンポーネントに`key`を付与することです。

選択を行うコンポーネントである`ItemSelector`に、条件に応じて変化する`key`を`prop`として付与します。

```tsx
<ItemSelector key={selectedKey} items={items} />
```

https://codesandbox.io/embed/fhdhpp?view=preview&module=%2Fsrc%2FApp.tsx

Reactは同じコンポーネントが同じ場所でレンダーされると状態を保持します。これが`items`を変更しても、`ItemSelector`が同じ`selectedId`を持ち続ける理由です。
つまり、`ItemSelector`が異なる`selectedId`を持つようにするには、異なるコンポーネントか、異なる場所にレンダーさせる必要があります。
そして、同一のコンポーネントであっても異なる`key`を持ったコンポーネントは異なるコンポーネントとしてReactに伝えられます。
このような背景があり、`items`の変化に対応して変化する`key`を`ItemSelector`に渡しています。この方法では`selectedId`だけではなく`ItemSelector`の全ての状態が初期化されることに注意してください。

この解決方法は、`ItemSelector`の内部の実装を加味して、外から`key`を付与必要がある点に問題が潜んでいます。

下のようにコンポーネントを間に挟むか(`key`に渡す変数名が雑すぎるので真似しないほうが良いです)
```tsx
const ItemSelector: FC<{
  itemsId: string;
  items: readonly Item[];
}> = ({ itemsId, ...props }) => {
  return <_ItemSelector key={itemsId} {...props} />;
};

const _ItemSelector = ...

```
次に紹介する解決法を利用することでその問題を解決します(次に紹介する方法の方が優れているわけではないです)。

## 前のpropを記憶する
1つ前のレンダー時の`items`を状態として保持しておき、変化があったら`selectedId`を初期化する方法です。

```tsx
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 ...
};
```

https://codesandbox.io/embed/h6qrg2?view=preview

`prevItems`という状態を作成し、`prop`である`items`に変更がないかをレンダー毎に確認しています。そして、変更があれば`selectedId`の初期化と`prevItems`の更新を行います(例では簡単のためitemsがメモ化されていることを前提としているコードです。比較が正常に行われていることは慎重に確認してくだs)。

この方法では、`items`の変更によって`ItemSelector`の再レンダーのタイミングで状態が更新され、さらにもう一度レンダリングされることになりますが、DOMの更新は一度で済むので`useEffect`に比べてコストが少ない更新になります(再レンダーはJavaScriptの計算だけなので大きな欠点とは考えていないです)。

このコードは理解し辛いので、2つ目の解決策を優先的に利用することが推奨されます。
しかし、汎用的なコンポーネントを作りたく、外部から強制的に`key`を付与させる仕様等を避けたい場合や、特定の状態のみを初期化したい場合にはこの方法が最良になります。

## さいごに
状態をpropの変化に応じて更新する方法を紹介しました。1つ目の方法は避けて欲しいですが、2つ目と3つ目は一長一短があるので状況によって使い分けると良いです。

そして、このような状況に陥った時、今回紹介した方法へ書き直す前に考えて欲しいことがあります。それは「本当に状態にする必要があるか」です。propを用いて計算できないか、他の状態を組み合わせて計算できないかを考えて、必要以上に状態を作らないようにして下さい。