🐷

そのuseState、もっと減らせるかも?コンポーネントの事例と一緒に示します

2023/03/24に公開
2

概要

ReactにおいてuseStateを使ってステートを管理するのは常套手段ですが、一方で、不必要な場面なのに使ってしまうケースもありえます。

無駄なuseStateは減らすべきだとわかっていても、具体的にどういう場面で無駄に使ってしまうのかわからない方も多いかもしれません。そこで、先日実装した簡単なコンポーネントをもとに、無駄なフックを使っているソースコードと、その解決策、解決後のソースコードを示します。

対象読者

  • React初級者〜中級者
  • 各種フックをサクサク使える
  • 無駄なフックを減らそう、という話題を見て、心当たりがある

私について

Reactのソースレビューを通して、useEffect撲滅委員会、useStateを減らそう委員会、TypeScriptのasを撲滅する委員会に所属しております。

image
image
image

サンプルコンポーネント

日付の範囲指定ができるコンポーネントです。動作イメージは動画を再生してください。

  • 最初は今日から直近1週間の日付が表示されている
  • 左矢印を押すことで1週間ずつ、前にさかのぼっていける
  • 過去に戻っているときは右矢印も表示され、それを押すと前に進む
  • 最初は1週間ずつ表示、前後に移動ができるが、1ヶ月(30日)毎に切り替えることもできる(動画の途中で月単位に切り替わります)
    • 切り替えるコンポーネントは別に定義されている想定です
    • 切り替えると表示される日付の範囲は今日に戻ります

video

このコンポーネントの実装例を以下に示します。

import type { FC } from 'react';
import {
  useEffect,
  useMemo,
  useState
} from 'react';
import dayjs from 'dayjs';

type Props = {
  interval: 'weekly'|'monthly';
};

export const DateRangeChanger: FC<Props> = ({ interval }) => {
  // intervalをもとに、何日間の範囲で表示するかを求めた値
  const intervalDateCount = interval === 'weekly' ? 7 : 30;

  // 表示範囲の最初の日付
  const [startDate, setStartDate] = useState<dayjs.Dayjs>(() => {
    return dayjs().subtract(intervalDateCount - 1, 'day');
  });
  // 表示範囲の最後の日付
  const [lastDate, setLastDate] = useState<dayjs.Dayjs>(dayjs());

  // intervalが変わったときに、日付の範囲を最初に戻す
  useEffect(() => {
    setLastDate(dayjs());
    setStartDate(dayjs().subtract(intervalDateCount - 1, 'day'));
  }, [interval]);

  // 前に戻るボタンを押したとき
  const backToPrevious = () => {
    setLastDate(date => date.subtract(intervalDateCount, 'day'));
    setStartDate(date => date.subtract(intervalDateCount, 'day'));
  };
  // 後に進むボタンを押したとき
  const proceedToNext = () => {
    setLastDate(date => date.add(intervalDateCount, 'day'));
    setStartDate(date => date.add(intervalDateCount, 'day'));
  };

  const showPrevButton = true;

  const showNextButton = useMemo(() => {
    return lastDate.isBefore(dayjs(), 'day');
  }, [lastDate]);

  return (
    <div>
      <div>
        {showPrevButton && <button onClick={backToPrevious}>{'<'}</button>}
     <span>{startDate.format('YYYY年M月D日')}</span>
        <span></span>
        <span>{lastDate.format('YYYY年M月D日')}</span>
        {showNextButton && <button onClick={proceedToNext}>{'>'}</button>}
      </div>
    </div>
  );
};

このコンポーネントは週または月の開始日と終了日を表示するので、それぞれをステートで管理しています。
開始日の初期値は今日から6日または29日前、終了日の初期値は今日ですので、それぞれ初期値をuseState関数の引数に渡して設定しています。

前に進むボタンと後に進むボタンを押したとき、開始日と終了日のステートをそれぞれ更新しています。

このソースコードを見て、ひと目でuseStateに関する改善点が見つかった方は次章以降は読まなくて大丈夫です。思いつかなかった方、普段こんな感じでステート決めているなと思った方はぜひ次の章も読んでください。

改修後のサンプルコンポーネント

さて、このコンポーネントをさっそく改修してみました。改修後のソースコードが以下になります。

import type { FC } from 'react';
import { useMemo, useState } from 'react';
import dayjs from 'dayjs';

type Props = {
  interval: 'weekly'|'monthly';
};

export const DateRangeChanger: FC<Props> = ({ interval }) => {
  // intervalをもとに、何日間の範囲で表示するかを求めた値
  const intervalDateCount = interval === 'weekly' ? 7 : 30;

  // 必要なステートはlastDateだけ
  const [lastDate, setLastDate] = useState<dayjs.Dayjs>(dayjs());

  // startDateは常にlastDateからインターバルに応じて差し引いた日時になる
  const startDate = lastDate.subtract(intervalDateCount - 1, 'day');

  // intervalが変わったときに、日付の範囲を最初に戻す
  useEffect(() => {
    setLastDate(dayjs());
  }, [interval]);

  const backToPrevious = () => {
    setLastDate(date => date.subtract(intervalDateCount, 'day'));
  };
  const proceedToNext = () => {
    setLastDate(date => date.add(intervalDateCount, 'day'));
  };

  // 以下は改修していない
  const showPrevButton = true;

  const showNextButton = useMemo(() => {
    return lastDate.isBefore(dayjs(), 'day');
  }, [lastDate]);

  return (
    <div>
      <div>
        {showPrevButton && <button onClick={backToPrevious}>{'<'}</button>}
        {startDate && <span>{startDate.format('YYYY年M月D日')}</span>}
        <span></span>
        <span>{lastDate.format('YYYY年M月D日')}</span>
        {showNextButton && <button onClick={proceedToNext}>{'>'}</button>}
      </div>
    </div>
  );
};

書いてみると大したことはなく、startDateをuseStateでステートとして定義するのではなく、const startDate = lastDate.subtract(intervalDateCount - 1, 'day');のように変数定義で実装した、というのが改善点です。

このコンポーネントの定義では、props.intervalに応じてlastDateとstartDateの日付の差分は決まります。なので、startDateとlastDateそれぞれ別個でステート管理する必要はありません。lastDateをステート管理するならばstartDateはそこから機械的に算出できますし、逆にstartDateをステート管理するならば、lastDateはそこから算出できます。本例ではlastDateをステート管理する方針で実装してみました。

ステートを減らしたことによるメリット

さて、このようにステートの数を1つ減らしたことで、次に進んだり前に戻るボタンを押したときのイベントハンドラ内の実装がシンプルになっています。元々の実装では複数のステートを更新する必要がありましたが、改修後ではlastDateのみを更新すれば良くなっています。

また、ステートを2つ定義している改修前の実装の場合、物理的にはありえないstartDate/lastDateの組み合わせにすることが可能になっています。どういうことかというと、proceedToNext/backToPreviousなどの実装次第では、lastDateに4月30日を入れて、startDateに12月31日を入れるといったことが可能です。つまりバグを生む余地があります。

しかし改修後の実装では、ステートは1つだけになっており、また、startDateはlastDateとintervalから機械的に算出されているので、必ず仕様どおりの値が入ると言い切れます。

言い換えると、別々のステートとして定義するということは、それぞれ別個の状態として管理されることを宣言しているということであり、今回のような、片方の値が決まったらもう片方の値が決まるというケースでは不適だということです。また、ソースコードを後から読んだ人にとっても、別々のステートで宣言されているということは、各々のsetterを追いかけて変更される条件を見る必要が生じるため、可読性が低くなるといえます。

ちなみに(勝手に引用して恐縮ですが)以前Twitterで、アコーディオンUIの状態管理についてのツイートを見かけて同じことを思いました。アコーディオンUIも、各パネルの開閉を個別のステートで管理してしまったら、物理的には2つ以上Openになることがありえてしまうので、バグを生む余地があります。なので、「どれが空いているか(または一つも空いていないか)」だけをステートとして管理するほうが良いと言えます。
https://twitter.com/uhyo_/status/1634851847050768384?s=20

どう考えると無駄なステートを防げるのか

今回の例では、最終的なアウトプットに日付が2つ表示されていることから、うっかりステートが2つあると考えてしまいがちです。ですが、このコンポーネントが扱っている状態は、本質的には「指定された日付の幅」という1つの状態だけです。その幅が前後したり、props.intervalによってそもそも幅の長さが変わったりしているだけで、状態自体が2つあるわけではないです。
そう考えると、このコンポーネントにはステートは1つだけ用意すればいいな、と思いながら実装を始めるので、改修前のようなコードを書くことはなくなります。

この考え方は設計段階から状態について考えるトップダウンの考え方で理想的だと思います。
ですが、実際に実装してから違和感を覚えて改善するボトムアップの考え方もあります。

本例では、ステートを2つ用意しているのにも関わらず、片方のステートを変更するときにはもう片方のステートも必ず変更させるようにproceedToNext/backToPrevious等で実装していました。このように、別個のステートを用意しているのに同期をとるように実装している場合、ステートを減らせる可能性が高いです。そういったコードを見かけたり実装してしまった場合には、減らせないか考えてみるのが良いと思います。

設計段階で状態について考えられているのがもちろん理想ですが、実際は書いてみて考えることも多いので、ボトムアップ的にステートを改善することもできるといいでしょう。

React公式ドキュメントでの記載

React公式ドキュメントにおいても、あるステートから機械的に算出可能な値をステートとして宣言しないことが推奨されています(そもそもステートとは他のステートから算出可能なものではないこと、みたいな定義が書かれたページもあった気がするのですが、思い出せなかった)。

https://react.dev/learn/reacting-to-input-with-state#step-4-remove-any-non-essential-state-variables

補足

他のステートの持ち方

startDateだけ、またはlastDateだけをステートとして持つ案を本記事では示しましたが、「指定された日付の幅」という1つの状態を持つということから考えると、startDate/lastDateを持った一つのオブジェクトをステートの値として持つ案もあるかもしれません。

// たとえばこんな感じ
const [dateRange, dispatch] = useReducer(dateRangeReducer, { startDate: dayjs().subtract(intervalDateCount - 1, 'day'), lastDate: dayjs() })

useEffectも減らすには

useStateは減らしましたが、useEffectを減らせていないので、補足として以下のuseEffectを減らす方法も書いておきます。

  // intervalが変わったときに、日付の範囲を最初に戻す
  useEffect(() => {
    setLastDate(dayjs());
  }, [interval]);

このuseEffectの意図はintervalが変わったときに処理を動かしたいという意図ですが、そうなのであれば、intervalを変更するイベントハンドラ側で対応するとuseEffectを減らせます。

今回の例では書いていないですが、外からpropsでintervalが渡ってきており、途中で切り替えが可能ということは、切り替えを行うイベントハンドラ関数があるはずなので、そのあたりのステート管理とまとめて1つのカスタムフックにするのが結局いいと考えています。

type DateInterval = 'weekly'|'monthly'

const useDateRange = () => {
  const [interval, setInterval] = useState<DateInterval>('weekly');
  const [lastDate, setLastDate] = useState<dayjs.Dayjs>(dayjs());

  const handleChangeInterval = (interval: DateInterval) => {
    setInterval(interval);
    setLastDate(dayjs()); // こうするとintervalが変わったときにlastDateが変わる、をuseEffect不要で表現できる
  };

  // ...

イベントハンドラ側で実装すれば対応できる副作用をuseEffectで実装してしまうと、どういう理由で変更されたかに関わらず副作用が起きることになり、あとから処理が追いづらくなったり意図しないバグを生んでしまう可能性があるため、極力避けるのが良いと思います。

無駄なuseMemoについて

これは蛇足ですが、以下のように単純な計算で実装できるものを、

  // intervalをもとに、何日間の範囲で表示するかを求めた値
  const intervalDateCount = interval === 'weekly' ? 7 : 30;

useMemoでついつい囲っている方もいるのではないでしょうか。

  // intervalをもとに、何日間の範囲で表示するかを求めた値
  const intervalDateCount = useMemo(() => {
    return interval === 'weekly' ? 7 : 30;
  }, [interval]);

この程度の単純な計算をuseMemoでメモ化しても効果がほとんど無い上に、むしろ無駄にuseMemo関数を実行していることになったり、依存配列にうっかり値を入れ忘れてバグを引き起こすなどのリスクがあるので、やめたほうがよいと思っています。

▼参考Tweet(Reactの新しいドキュメントを書いたけど、useMemoはほとんど使わなかったし、多くのコードでは問題も無いという旨のツイート)
https://twitter.com/dan_abramov/status/1638010972060241920?s=20

▼React公式ドキュメントでuseMemoは高コストな計算を行う処理で利用すると書かれている
https://react.dev/reference/react/useMemo#skipping-expensive-recalculations


まとめ

  • 無駄なuseStateを使っていると、各々のステートが意図しない状態になる可能性が生じたり、可読性の低下を招く
  • 無駄なステートを減らすには、そもそもコンポーネントが扱う状態って何個だろうかと考えるトップダウンのアプローチと、実装された結果を見て、片方の状態がもう片方の状態から機械的に算出されていることを見抜いて改善するボトムアップのアプローチがあり、両方を使いこなすのが大事
  • 無駄なuseEffectやuseMemoも減らすことができる

最後まで読んでいただきありがとうございました!記事が参考になったらプロテイン代(という名のバッジ)を恵んでください!

マナリンク Tech Blog

Discussion

fiorizenfiorizen

そもそもステートとは他のステートから算出可能なものではないこと、みたいな定義が書かれたページもあった気がするのですが、思い出せなかった

こちらですかね。

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://react.dev/learn/choosing-the-state-structure