🙇‍♂️

【React】state構造の原則をおさらいしてstateの使い方を反省する

2024/09/24に公開

概要

「このstateいる?」や「このstate更新しずら!」と感じる場面は多々あると思います。
いくつstateを使うのか、データ構造をどのようにするのかについて言語化が難しく、もっと考えてstateを使わなければといけないと感じたため、今一度、原点に立ち返りつつ、state構造の原則をおさらいして反省したいと思います。

state構造の原則

state構造の原則については公式から5つの原則が設けられており、これらの原則の背景にはミスを入りこませずにstateを容易に更新できるようにすることが目的となっている。
最適とはいえないstate構造でも動くコードを作ることは可能ではあるが、冗長で重複するデータを取り除くことで、関連するstateが同期した状態を保てるようになる。

関連するstateはグループ化する

複数のstateが常に同時に更新される場合は、それらはオブジェクトとして一つのstateにまとめることで、管理を容易にし、不整合を防ぐことができる。

個別に管理する場合

const [a, setA] = useState(0)
const [b, setB] = useState(0)

個別に管理する問題点として、aとbが常に同時に更新される必要がある場合、二つの set 関数の呼び出しにより、更新の整合性を管理するのが煩雑になる可能性がある。 また、更新ロジックが複雑になると、バグや不整合が生じるリスクが高まる。

オブジェクトでグループ化する場合

const [value, setValue] = useState({ a: 0, b: 0 })

aとbを同時に更新する場合、オブジェクト全体をまとめて更新できる。この場合の、メリットとして

setValue(prevValue => ({
  ...prevValue,
  a: prevValue.a + 1,
  b: prevValue.b + 1
}));

更新の簡素化が可能になり、一度のset関数呼び出しで複数の状態を更新できるため、パフォーマンスが向上することがある。(再レンダリングの回数が減る)

個別に管理するべきケース

  • 各stateが独立している場合
  • 変更頻度が異なる場合: あるstateは頻繁に変わり、もう一方のstateは滅多に変わらない場合は、個別に管理した方が効率的な場合がある。

stateの矛盾を避ける

stateの矛盾とは、複数のstateの更新タイミングのミスなどで、矛盾したstateが出来上がってしまうことである。

const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

送信中と送信完了を管理するisSendingisSentというstateがあるとする。
この場合、更新タイミングのミスなどで、isSendingとisSentがどちらもtrueになるという矛盾が生じる危険性がある。

この場合の対処として、一つのstateでstatusを管理するのが適切である。

const [status, setStatus] = useState<'typing' | 'sending' | 'sended'>('typing');

冗長なstateを避ける

レンダー中にpropsや既存のstateから情報を計算できる場合、その情報をstateで管理するべきではない。
複数のstateの更新タイミングのミスなどで、矛盾したstateが出来上がってしまう危険がある。

例えば、以下のように金額、量、総額のstateが存在していたとする。

const [price, setPrice] = useState(100);
const [quantity, setQuantity] = useState(2);
const [totalPrice, setTotalPrice] = useState(200);

この場合、priceとquantityのどちらかが更新されたタイミングでtotalPriceも更新させないとstateに矛盾が生じてしまう。

このように状態を計算で導出できる場合はstateに保存しないのが適切である。(そもそもstateで管理しない)

const [price, setPrice] = useState(100)
const [quantity, setQuantity] = useState(2)
const totalPrice = useMemo(() => price * quantity, [price, quantity])

state内の重複を避ける

整合性の観点から同じデータが複数のstateに存在しないように設計するべきである。
例えば、メニューが管理されているstate(items)と選択したメニューを管理しているstate(selectedItem)が存在していたとする。

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];
const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(items[0]);

このstate管理の懸念としては、例えばitemsのtitleが更新されたタイミングでselectedItemのtitleも更新する必要があり、不整合が起こる危険性がある。

この場合の対処としては、更新されないidをstate管理し、そこからselectedItemを抽出することである。

const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);
const selectedItem = useMemo(
    () => items.find((item) => item.id === selectedId),
    [items, selectedId],
  )

深くネストされたstateを避ける

深くネストされたstateの場合、更新の際に親オブジェクトや配列をコピーし、そこから対象のプロパティを更新する必要がある。ネストが深くなるほど、更新ロジックが煩雑になり、ミスが発生しやすくなります。

const initialState = {
  name: 'John',
  address: {
    street: '123 Main St',
    city: 'Somewhere',
    zip: '12345'
  },
  preferences: {
    notifications: [
      { type: 'email', enabled: true },
      { type: 'sms', enabled: false },
      { type: 'push', enabled: true }
    ]
  },
  orders: [
    {
      id: 1,
      items: [
        { name: 'Laptop', price: 999.99 },
        { name: 'Mouse', price: 25.99 }
      ]
    },
    {
      id: 2,
      items: [
        { name: 'Keyboard', price: 49.99 },
        { name: 'Monitor', price: 199.99 }
      ]
    }
  ]
};

この場合は、useReducerを使って可読性を上げることも考えられるが、正規化させることが推奨されている。

const normalizedState = {
  name: 'John',
  address: {
    id: 1,
    street: '123 Main St',
    city: 'Somewhere',
    zip: '12345'
  },
  notifications: {
    byId: {
      1: { id: 1, type: 'email', enabled: true },
      2: { id: 2, type: 'sms', enabled: false },
      3: { id: 3, type: 'push', enabled: true }
    },
    allIds: [1, 2, 3]
  },
  orders: {
    byId: {
      1: {
        id: 1,
        items: [1, 2] // item IDs
      },
      2: {
        id: 2,
        items: [3, 4] // item IDs
      }
    },
    allIds: [1, 2]
  },
  items: {
    byId: {
      1: { id: 1, name: 'Laptop', price: 999.99 },
      2: { id: 2, name: 'Mouse', price: 25.99 },
      3: { id: 3, name: 'Keyboard', price: 49.99 },
      4: { id: 4, name: 'Monitor', price: 199.99 }
    },
    allIds: [1, 2, 3, 4]
  }
};

sms通知を変更する例

const updateSmsNotification = (newSetting) => {
  setState(prevState => ({
    ...prevState,
    notifications: {
      ...prevState.notifications,
      byId: {
        ...prevState.notifications.byId,
        2: {
          ...prevState.notifications.byId[2],
          enabled: newSetting
        }
      }
    }
  }));
};

Discussion