🍔

新React Docs(beta)からリファクタを通して正しいhooksの使い方を学ぶ

2022/07/19に公開

はじめに

Reactの新しいドキュメントがbeta版ですが公開されているようです。hooksの正しい使い方やしくみがまとめられていてとても勉強になります。

今回はReact Docs(beta)を読んで正しいhooksの使い方を学び、自分のよろしくないコードをリファクタしていこうと思います。

Reactを勉強したての初心者や雰囲気で書いてる人には学びがあるんじゃないかなと思います。

https://beta.reactjs.org/

リファクタするもの

以下のコードをリファクタしていきます。

親のチェックボックスで子のチェックボックスを制御できるようなフォームです。

このコードは正しく動作しますが、いくつかhooksの正しくない使い方が含まれています。

よくない点1:useStateの構造

下記のようにチェックボックスの状態をusersから生成しstateで管理しています。

const [checkStates, setCheckStates] = useState(
    users.map((user) => ({ ...user, checked: false }))
 );

型構造は下記の通りです。

type CheckStates = {
  id: number,
  name: string,
  checked: boolean
 }[]

Avoid duplication in state. When the same data is duplicated between multiple state variables, or within nested objects, it is difficult to keep them in sync. Reduce duplication when you can.
https://beta.reactjs.org/learn/choosing-the-state-structure#principles-for-structuring-state

React Docs(beta)によるとstateは他のstateとの重複を避けるべきとあります。
今回の例だとcheckStatesusersの情報と重複しています。

必要なのはチェックしているユーザーのidのみなので素直に

const [checkedItems, setCheckedItems] = useState<number[]>([]);

としてチェックしているuserのIDのみを管理するのが適切です。

よくない点2:useEffectの使い方が適切でない

元のコードではcheckStatesの変更をチェックし、checkStatesの変更を検知して親のチェックボックスの状態を変更しています。とあるstateが変更されたら別のstateを変更させるという処理をuseEffect内で行っています。

  useEffect(() => {
    if (checkStates.every((c) => c.checked)) {
      setIsAllChecked(true);
      setIsIndeterminate(false);
    } else if (checkStates.some((c) => c.checked)) {
      setIsAllChecked(false);
      setIsIndeterminate(true);
    } else {
      setIsAllChecked(false);
      setIsIndeterminate(false);
    }
  }, [checkStates]);

このようなuseEffectの使い方は何も考えずに書くと割としがちだと思います。

しかしこのuseEffectの使い方は適切ではありません。

Don’t rush to add Effects to your components. Keep in mind that Effects are typically used to “step out” of your React code and synchronize with some external system. This includes browser APIs, third-party widgets, network, and so on. If your effect only adjusts some state based on other state, you might not need an Effect.
https://beta.reactjs.org/learn/synchronizing-with-effects#you-might-not-need-an-effect

React Docs(beta)によるとuseEffectはReact外部の副作用と内部を同期させるために使用します。

React外部とは例えばAPIのfetch処理であったり、ReactがカバーしていないブラウザのAPIへのアクセスなどです。

今回の使い方はstateを変更させるという意味では副作用ではあるのですがReact内部での副作用ですので適切ではありません。

useEffect内の処理は画面を描画し終わったタイミングで行われます。useEffect内のsetStateによって再レンダリングがトリガーされるため、無駄なレンダリングが多くなってしまいます。

このuseEffectは以下のようにリファクタできます。

解決策1: ハンドラー関数に含める

親のチェックボックスの状態を変更させている直接の要因は、ユーザーがチェックボックスにチェックを入れたり、外したりしていることです。

そのため、ハンドラー関数内の処理に含めるのが今回だと適切です。

  const onChange = (id: number) => {
    const next = checkStates.map((checkState) => {
      if (checkState.id === id)
        return { ...checkState, checked: !checkState.checked };
      return checkState;
    });
    setCheckStates(next);
+    if (next.every((c) => c.checked)) {
+      setIsAllChecked(true);
+      setIsIndeterminate(false);
+    } else if (next.some((c) => c.checked)) {
+      setIsAllChecked(false);
+      setIsIndeterminate(true);
+    } else {
+      setIsAllChecked(false);
+      setIsIndeterminate(false);
+    }
  };

ちなみに上のコードでsetStateが連続して書かれているため、レンダリングもsetStateの数だけ行われるように勘違いしそうになりますが、レンダリングは一度しか行われません。

Reactは各setStateを見つけるとキューに入れていきます。ハンドラー内の処理は最後まで実行され、その後キューを使用してまとめてレンダリングが行われます。

useStateの仕組みについては、以下のページが参考になります。setState(count + 1)setState(count => count + 1)の挙動の違いがuseStateの仕組みをもとに説明されていてとても勉強になります。

https://beta.reactjs.org/learn/queueing-a-series-of-state-updates

解決策2: レンダリング中の処理に含める

React Docsによるとレンダリング中の処理にsetStateを含めることはアリなようです。ただし、コード中にもあるようにこれはbetterな方法です。

下のコードでsetSelection(null)に差し掛かった時点で、return文を待たずしてレンダリングをスキップし新しいレンダリングを開始するようです。

setSelection(null)以前の処理が無駄になってしまうためbestではなくbetterなのだと考えています。

ハンドラー内に含めることができない場合の手段として有効だと考えられます。

https://beta.reactjs.org/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // Better: Adjust the state while rendering
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

よくない点3:無駄なstate

元のコードにはuseStateが3つ含まれています。

  • チェックされたアイテムのstate
  • 親のチェックボックスがチェックされているかどうかのstate
  • 親のチェックボックスがindeterminateかどうかのstate
  const [checkedItems, setCheckedItems] = useState<number[]>([]);
  const [isAllChecked, setIsAllChecked] = useState(false);
  const [isIndeterminate, setIsIndeterminate] = useState(false);

Avoid redundant state. If you can calculate some information from the component’s props or its existing state variables during rendering, you should not put that information into that component’s state.
https://beta.reactjs.org/learn/choosing-the-state-structure

React Docsによるとstateには他のstateから計算できるものは含めるべきではないとあります。

下記のコードはReact Docsからの引用です。fullNamefirstNamelastNameから計算可能なのでstateではなく単なる変数にすべきという例です。

  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
- const [fullName, setFullName] = useState('');
+ const fullName = firstName + ' ' + lastName

今回のコードもstateを3つから1つに減らすことができます。
isAllCheckedisIndeterminatecheckedItemsから計算可能なのでstateで管理すべきではありません。

stateを追加したくなったらそのstateは他のstateから計算可能かチェックしてみると良いでしょう。。

  const [checkedItems, setCheckedItems] = useState<number[]>([]);
-  const [isAllChecked, setIsAllChecked] = useState(false);
-  const [isIndeterminate, setIsIndeterminate] = useState(false);
+  const isAllChecked = checkedItems.length === users.length
+  const isIndeterminate = !isAllChecked && checkedItems.length > 0

isAllCheckedisIndeterminateはレンダリングごとに再計算されます。もしこの計算がコストのかかる計算ならuseMemoでラップすると良いでしょう。

const isAllChecked = useMemo(() => checkedItems.length === users.length, [
  checkedItems,
]);

上のコードだと再レンダリング時にcheckedItemsに変更がなければ再計算は行わず古い値を使用してくれます。

今回は必要なさそうです。

完成

最終的にコードは下のようになります。無駄なuseEffectとstateが減ってめっちゃすっきりしました。

export default function App() {
  const [checkedItems, setCheckedItems] = useState<number[]>([]);
  const isAllChecked = checkedItems.length === users.length;
  const isIndeterminate = !isAllChecked && checkedItems.length > 0;

  const handleAllChenge = () => {
    if (isAllChecked) {
      setCheckedItems([]);
    } else {
      setCheckedItems(users.map((u) => u.id));
    }
  };

  const onChange = (id: number) => {
    if (checkedItems.includes(id)) {
      setCheckedItems(checkedItems.filter((c) => c !== id));
    } else {
      setCheckedItems([...checkedItems, id]);
    }
  };

  const onSubmit = () => {
    alert(checkedItems.join(", "));
  };

  return (
    <div className="App">
      <div>
        <CheckBox
          id="parent"
          checked={isAllChecked}
          onChange={handleAllChenge}
          isIndeterminate={isIndeterminate}
        />
        <label htmlFor="parent">全て選択</label>
      </div>
      <div style={{ marginLeft: 30 }}>
        {users.map((user, i) => {
          return (
            <div>
              <CheckBo
                id={`checkbox${user.id}`}
                checked={checkedItems.includes(user.id)}
                onChange={() => onChange(user.id)}
              />
              <label htmlFor={`checkbox${user.id}`}>{user.name}</label>
            </div>
          );
        })}
      </div>
      <button onClick={onSubmit}>Submit</button>
    </div>
  );
}

動作

まとめ

  • useStateの構造はシンプルにする。
  • 他のstateから計算できるものはstateにしない。
  • useEffectはReact外部との同期以外には使用しない。

他にもReact Docsにはhooksを扱うのに役立つtipsやルールがたくさんあるのでまだ見てない方はぜひ見てみてください。

おわりに

新React Docsはこれを読めば他のReact解説記事や技術書は必要ないんじゃないかというくらい充実しています。

まだbeta版ということもありuseMemoやuseCallbackあたりのパフォーマンスについての記事も追加されそうな雰囲気なので楽しみですね。

この記事が参考になれば幸いです!

https://beta.reactjs.org/

Discussion