【React】useStateの乱用を避ける

2024/02/06に公開

はじめに

React の状態管理で最も基本的な方法は、useState フックを使ったローカルな状態管理です。ただ、何でもステートにしておけば良いというものでもありません。今回は、具体的なケースを利用して、useStateを使わない選択肢について説明します。

お題

今回は、全選択可能なチェックボックスを実装することを考えます。以下のような、テーブルの行を複数選択できるようなチェックボックスの UI を想定してみましょう。

各項目には、選択状態を表すチェックボックスがあり、ヘッダー部分には全項目を一括で操作するためのチェックボックスがあります。

各項目のチェックボックスは、チェックが有るか無いかの 2 通りです。ヘッダー部分のチェックボックスは、全項目が選択されているか、全項目が未選択か、選択と未選択が混じっているかの 3 通りになります。ちなみに、HTML のチェックボックスで混じっている状態を表現するには、indeterminate属性を使うので、この記事でも混じっている状態を indeterminate と呼ぶことにします。

なお、実際のプロダクトでは、ページングやフィルターなど考慮事項が多くなるかと思いますが、今回はそれらを考慮せず、全項目が一覧で表示されている状態を想定します。

状態を設計する

まずは、この UI を実装するために、どのような状態を持つ必要があるかを考えてみましょう。

最初に考えられるのは、各項目の選択状態を管理する状態です。各項目の選択状態は、チェックボックスが有るか無いかの 2 通りなので、boolean型で表現できます。この状態は、各項目ごとに持つ必要があります。

項目の全数は未定なので、これらの状態を配列で持つことにします。すなわち、boolean[]型の状態を持つことになりますね。

次に、ヘッダー部分の選択状態を管理する状態です。ヘッダー部分の選択状態は、全項目が選択されているか、全項目が未選択か、選択と未選択が混じっている(indeterminate)かの 3 通りになります。残念ながら TypeScript には(そして、他の主要な言語にも) 3 通りの状態を表現する型が用意されていないので、boolean | 'indeterminate'型で表現することにします。trueは全選択、falseは全未選択、'indeterminate'は混じっている状態を表します。

以上を踏まえると、以下のような状態を持つことになります。

// 個々の項目の選択状態
const [selected, setSelected] = useState<boolean[]>([]);
// ヘッダー部分の選択状態
const [headerSelected, setHeaderSelected] = useState<boolean | 'indeterminate'>(
  'indeterminate'
);

実装する

それでは、実際にこの状態を使って、UI を実装してみましょう。まずは、各項目のチェックボックスの部分です。

TechStackList.tsx
const TECH_STACKS = ['HTML', 'CSS', 'JavaScript', 'PHP', 'Swift'];

const TechStackList = () => {
  // 個々の項目の選択状態
  const [selected, setSelected] = useState([false, false, false, false, false]);
  // ヘッダー部分の選択状態
  const [headerSelected, setHeaderSelected] = useState<
    boolean | 'indeterminate'
  >(false);

  return (
    <div>
      {TECH_STACKS.map((techName, i) => (
        <div key={i}>
          <Input
            type="checkbox"
            // selectedを参照する
            checked={selected[i]}
            onChange={(e) => {
              const newSelected = [...selected];
              // selectedを更新する
              newSelected[i] = e.target.checked;
              setSelected(newSelected);
            }}
          />
          <div>5年</div> {/* モック値 */}
          <div>{techName}</div>
        </div>
      ))}
    </div>
  );
};
Input コンポーネントの定義

素の Input は、indeterminate 属性は JS から変更する必要があります。そのため、今回は以下のような input のラッパーコンポーネントを作成し、利用する形にしています。

checked が indeterminate の場合、input の indeterminate 属性を true にするようにしています。

import React from 'react';

type InputProps = Omit<React.ComponentProps<'input'>, 'checked'> & {
  checked: boolean | 'indeterminate';
};

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({checked, ...props}, ref) => {
    useEffect(() => {
      if (ref.current) {
        ref.current.indeterminate = checked === 'indeterminate';
      }
    }, [checked]);

    return <input {...props} checked={checked === true} ref={ref} />;
  }
);

export default Input;

次に、ヘッダーのチェックボックスです。ヘッダーのチェックボックスは、全て選択されている場合は true、全て未選択の場合は false、混じっている場合は 'indeterminate' になるのでした。

TechStackList.tsx
const TECH_STACKS = ['HTML', 'CSS', 'JavaScript', 'PHP', 'Swift'];

const TechStackList = () => {
  const [selected, setSelected] = useState([false, false, false, false, false])
  const [headerSelected, setHeaderSelected] = useState<
    boolean | 'indeterminate'
  >(false)

  return (
    <div>
      <div>
+        <Input
+          type="checkbox"
+          // headerSelectedを参照する
+          checked={headerSelected}
+          onChange={(e) => {
+            // headerSelectedを更新する
+            setHeaderSelected(e.target.checked)
+            // selectedを更新する
+            setSelected(
+              selected.map(() => {
+                return e.target.checked;
+              })
+            )
+          }}
+        />
      </div>
      <div>経験年数</div>
      <div>分類</div>
      {/* 省略 */}
    </div>
  )
}

しかし、これでは不十分です。実際にヘッダーのチェックボックスが変化するシナリオを考えてみましょう。例えば、headerSelectedtrueに変化するケースを列挙すると以下のようになります。

  1. ヘッダーのチェックボックスがfalseまたはindeterminateの状態で、そのチェックボックスがクリックされた場合
  2. 一部の項目が未選択の状態から、すべての項目が選択された場合

先ほどの実装では、1 のケースはカバーできていましたが、2 はカバーできていません。そこで、selectedの状態が変化した場合に、headerSelectedの状態を更新するロジックを追加します。

const TECH_STACKS = ['HTML', 'CSS', 'JavaScript', 'PHP', 'Swift'];

const TechStackList = () => {
  const [selected, setSelected] = useState([false, false, false, false, false])
  const [headerSelected, setHeaderSelected] = useState<
    boolean | 'indeterminate'
  >(false)

+  // selectedが変化したら、headerSelectedを更新する
+  useEffect(() => {
+    if (selected.every((s) => s)) { // 全てが true の場合
+      setHeaderSelected(true)
+    } else if (selected.every((s) => !s)) { // 全てが false の場合
+      setHeaderSelected(false)
+    } else {
+      setHeaderSelected('indeeterminate')
+    }
+  }, [selected])

  return (
    // 省略...
  )
}

さて、useEffectが登場しました。useEffectは、第 2 引数に指定したリアクティブ値が変化した場合に、第 1 引数の関数を実行するフックです。今回の場合、selectedの状態が変化した場合に、headerSelectedの状態を更新するために使用しています。

再考する

さて、ここまで実装してみて、改めて状態の設計を見直してみましょう。このコンポーネントには、selectedheaderSelectedという 2 つの状態があります。しかし、この 2 つの状態は完全に独立していません。

例えば、selected[true, false, false](1 つだけ選択されいる状態)のときに、headerSelectedtrueになることはありません。逆に、selected[true, true, true](全て選択されている状態)のときに、headerSelectedfalseになることもありません。headerSelectedtrueになるのはいつでも、selected[true, true, true]のときだけです。

あり得る組み合わせを考えてみると、以下のようになります。

しかし、現状のステート設計では、selectedheaderSelectedに不可能な組み合わせが存在します。これは、コンポーネントの実装が複雑になる原因になります。

これを踏まえると、headerSelectedselectedに依存している、と考えることができます。

言い換えると、headerSelectedselectedから導出することができます。(React 公式ドキュメントの日本語訳では「計算できる」と表現されていますが、個人的には導出という単語の方がイメージが伝わりやすいと思うので、導出を利用します)

これを純粋に実装に落とし込むと以下のようになります。

const [selected, setSelected] = useState<boolean[]>([]);

// selectedから導出する
const headerSelected = selected.every((s) => s) // 全て true の場合
  ? true
  : selected.every((s) => !s) // 全て false の場合
  ? false
  : 'indeeterminate';

先ほどは一つのステートとして独立していたheaderSelectedですが、selected から直接導出することにしました。先ほどのように useEffect を使う必要はなく、全体としてシンプルな実装になりました。

三項演算子を避ける

入れ子になった三項演算子は、コードの可読性を下げる要因になります。上記のコードはロジックのわかりやすさを優先して入れ子にしましたが、可読性を重視する場合は以下のようなリファクタが可能です。

let headerSelected;
if (selected.every((s) => s)) {
  headerSelected = true;
} else if (selected.every((s) => !s)) {
  headerSelected = false;
} else {
  headerSelected = 'indeterminate';
}

また、さらに let の使用を避けるには、関数に切り出すことも考えられます。

const getHeaderSelected = (selected: boolean[]) => {
  if (selected.every((s) => s)) {
    return true;
  } else if (selected.every((s) => !s)) {
    return false;
  } else {
    return 'indeterminate';
  }
};

// ...

const headerSelected = getHeaderSelected(selected);

更新処理

また、更新時の処理も簡潔になります。先ほどは、ヘッダーのチェックボックスがクリックされた際には、selectedheaderSelectedの両方を更新していました。しかし、headerSelectedselectedから導出されるため、大元のselectedのみを更新するだけで十分です。

実装の差分は以下のようになります。

  return (
    <div>
      <div>
        <Input
          type="checkbox"
          checked={headerSelected}
          onChange={(e) => {
-            // headerSelectedを更新する
-            setHeaderSelected(e.target.checked)
            // selectedを更新する
            setSelected(
              selected.map(() => {
                return e.target.checked;
              })
            )
          }}
        />
      </div>
      <div>経験年数</div>
      <div>分類</div>
      {/* 省略 */}
    </div>
  )
}

パフォーマンス

さて、ここで懸念されるのがパフォーマンスの問題です。コンポーネント内でレンダリング時に計算を行なっているため、リレンダリングが発生するたびに計算コストが発生します。

今回の事例では単に every を実行しているだけなので、パフォーマンスが問題になることはほとんどないでしょう。しかし、コストの高い計算を行なっていたり、コンポーネントのリレンダリング頻度が高い場合は、パフォーマンスの低下を引き起こす場合があります。

このような場合には、useMemoフックを使って、headerSelectedの状態をキャッシュすることができます。

const [selected, setSelected] = useState<boolean[]>([]);
const headerSelected = useMemo(() => {
  if (selected.every((s) => s)) {
    return true;
  } else if (selected.every((s) => !s)) {
    return false;
  } else {
    return 'indeeterminate';
  }
}, [selected]);

useMemo の第一引数に渡した関数は、第二引数に渡した状態が変化した場合にのみ実行されます。このため、selectedの状態が変化しない限り、何度リレンダリングが発生しても、headerSelectedの状態を導出するための計算は発生しません。(より厳密には、selecetdが変化していない場合は、直前のレンダーで計算された結果が再利用されます)

headerSelected はステート?

上記のコードでは、selecteduseState によって定義されたステートです。では、headerSelectedはステートでしょうか?

厳密にはステートではありません。しかし、state や props をまとめた概念である、「リアクティブ値」というグループに含まれます。リアクティブ値というのは、props、state および、これらからレンダリング時に導出される値のことを指します。

リアクティブ値は、useEffect や useMemo の中で参照する場合には、依存配列に追加するべきというルールがあります。

まとめ

今回は、複数選択可能なチェックボックスを題材に React の状態管理について考えました。

全てを state にするのではなく、state から導出する形で表現できる値もあることを意識することで、コンポーネントの実装が簡潔になります。また、これによってパフォーマンスが問題になる場合には、useMemoを使って処理の負荷を軽減することができます。

Discussion