Reincarnated as anyone

フロントエンド学習メモ

【学習メモ】 useReducer の本質:良いパフォーマンスのためのロジックとコンポーネント設計

はじめに

@uhyo_さんが書いた以下の記事を理解するために、原文を自分でも理解できる表現にリライトしたものになります。

useReducer の本質:良いパフォーマンスのためのロジックとコンポーネント設計

この記事はどんな記事?

どのような時にuseReducerを利用するのが適しているのかを説明した記事。

まとめ(記事の要約)

  • useReducerは、state に依存するロジックを、state に非依存な関数オブジェクト(dispatch)で表現できる。
  • state に依存するロジックを、state に非依存な関数オブジェクトで表現できることにより、React.memoによるパフォーマンス改善を行える。
  • useReducerを活かすには、state を1つにまとめることで、ロジックをなるべく reducer に詰め込む。

useReducerとは

関数コンポーネントで state 管理ができるようになるフック。

state 管理はuseStateuseReducerの 2 種類の方法があり、useReducerは複雑なロジックが絡んだ state を管理するのに適している。

useReducerの構文

const [currentState, dispatch] = useReducer(reducer, initialState);

引数として渡すのは以下の2つ。

  • reducer: 「現在の state」と「アクション」を受け取って「新しい state」を返す関数
  • initialState: state の初期値

useReducerの返り値は以下の2つ。

  • currentState: state の現在の値
  • dispatch: アクションを発火する関数

dispatchにアクションを渡すと、内部でreducerが呼び出されて新しい state が計算され、コンポーネントが再レンダリングされて新しい state が反映される。

reducerの例

const reducer = (state, action) => {
  if (action.type === "increment") {
    return {
      count: state.count + 1
    };
  } else {
    return {
      count: state.count - 1
    };
  }
};

受け取ったアクションに応じて、新しい state を返す。

この reducer では以下のアクションに応じた state を返す

  • { type: "increment" }: countを 1 増やしたstateを返す
  • { type: "decrement" }: countを 1 減らしたstateを返す

上記の reducer を以下のようにuseReducerに渡し、

const [state, dispatch] = useReducer(reducer, { count: 0 });

返り値であるdispatchに、以下のようにアクションを渡せば、state が更新される。

dispatch({ type: "increment" });

useReducerを利用することで、state を更新するロジックを reducer の1つに集約できる。

また、state の種類が増えたりロジックが増えたりしても、その操作の窓口がdispatchという1点に集約される。

コンポーネントが何かしらのロジックを発火したいときはdispatchを props で渡すだけでいいし、コンポーネントツリーが大きい場合はコンテキストを用いて子に伝えるのも有効。

useReducerがパフォーマンス改善につながる例

React アプリのパフォーマンス改善において大きな効果が出やすいのはReact.memoを活用すること(クラスコンポーネント時代のshouldComponentUpdatePureComponentに相当)。

これを活用してコンポーネントの余計な再レンダリングを避けることが、React アプリの基本的なパフォーマンス・チューニングとなる。

今回は、useReducerReact.memoの利用の助けになる例を見ていく。

初期状態のサンプル

まず、改善前の初期状態を見てみていく。

今回改善をするサンプルは、以下の機能を有している。

f:id:reincarnation777:20200907181111g:plain

  • 4 つの入力欄があり、それぞれに数値を入力することができる。
  • 入力欄の下には 4 つの数値を合計した値が表示される。
  • 入力欄の横にある「check」ボタンを押すと、そのときの数値が合計の何%かを一番下に表示する。
import React, { useState } from "react";

const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0);

const NumberInput = ({ value, onChange, onCheck }) => {
  return (
    <p>
      <input
        type="number"
        value={value}
        onChange={e => onChange(e.currentTarget.value)}
      />
      <button onClick={onCheck}>check</button>
    </p>
  );
};

export default function App() {
  const [values, setValues] = useState(["0", "0", "0", "0"]);
  const [message, setMessage] = useState("");

  return (
    <>
      {values.map((value, i) => {
        return (
          <NumberInput
            key={i}
            value={value}
            onChange={v =>
              setValues(current => {
                const result = [...current];
                result[i] = v;
                return result;
              })
            }
            onCheck={() => {
              const total = sum(values);
              const ratio = Number(value) / total;
              setMessage(
                `${value}は${total}の${(ratio * 100).toFixed(1)}%です`
              );
            }}
          />
        );
      })}
      <p>合計は{sum(values)}</p>
      <p>{message}</p>
    </>
  );
}

このコードは、ひとつの数値が変更されるたびに全てのNumberInputに再レンダリングが発生してしまう。

そのため、NumberInputReact.memoを適用して無駄な再レンダリングを減らしたい。

つまり、ひとつの数値が変更されたらそのNumberInputだけが再レンダリングされて、他のNumberInputは再レンダリングされないという状態が理想。

React.memo導入への努力

とりあえず、useStateのままでReact.memoの導入を試みる。

NumberInputReact.memoを適用して効果を得るためには、自分以外のNumberInputの入力値が変わっても props の内容が変化しないようにしなければいけない。

現状ではvalueは問題ないが、onChangeonCheckが問題。

<NumberInput
  // 省略...
  onChange={v =>
    // 省略...
  }
  onCheck={() => {
   // 省略...
  }}
/>

関数をベタ書きしているので、これらの props には毎回異なる関数オブジェクトが作られて渡されている。

そのため、React.memoは効かない。

毎回異なる関数オブジェクトが作られるのを防ぐために、useCallbackを利用する。

import React, { useState, useCallback } from "react";

const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0);

const NumberInput = ({ value, index, onChange, onCheck }) => {
  console.log("render NumberInput");
  return (
    <p>
      <input
        type="number"
        value={value}
        onChange={e => onChange(index, e.currentTarget.value)}
      />
      <button onClick={onCheck}>check</button>
    </p>
  );
};

export default function App() {
  const [values, setValues] = useState(["0", "0", "0", "0"]);
  const [message, setMessage] = useState("");

  const onChange = useCallback((index, value) => {
    setValues(values => {
      const newValues = [...values];
      newValues[index] = value;
      return newValues;
    });
  }, []);
  const onCheck = useCallback(
    index => {
      const total = sum(values);
      const ratio = Number(values[index]) / total;
      setMessage(
        `${values[index]}は${total}の${(ratio * 100).toFixed(1)}%です`
      );
    },
    [values]
  );

  return (
    <>
      {values.map((value, i) => {
        return (
          <NumberInput
            key={i}
            index={i}
            value={value}
            onChange={onChange}
            onCheck={onCheck}
          />
        );
      })}
      <p>合計は{sum(values)}</p>
      <p>{message}</p>
    </>
  );
}

これで問題は解決できたように見えるが、解決できていない。

useCallbackを利用することで、onChangeは同じ関数オブジェクトを渡すようになったが、以下のonCheckが問題。

const onCheck = useCallback(
  index => {
    const total = sum(values);
    const ratio = Number(values[index]) / total;
    setMessage(`${values[index]}は${total}の${(ratio * 100).toFixed(1)}%です`);
  },
  [values]
);

onCheckuseCallbackの第2引数が[values]となっているため、valuesが変わるたびにonCheckが再生成される。

onCheckが再生成されると、InputNumberが再レンダリングされるので、React.memoが意味をなしてない(再レンダリングを防げてない)。

そのため、onCheckvaluesに依存している状況をどうにかする必要がある。

一方で、onChangevaluesを利用しているが、valuesに依存していない。

const onChange = useCallback((index, value) => {
  setValues(values => {
    const newValues = [...values];
    newValues[index] = value;
    return newValues;
  });
}, []);

これは、useStateが提供する state 更新関数が、関数による state の更新をサポートしているから。

上記のコードでは、setValues関数の引数として「現在の state(values) を受け取って、新しい state を返す関数」を渡している。

そのため、onChangevaluesに依存していない。

つまり、onCheckmessageという state を更新するにあたって、それとは別のvaluesという state に依存していることが問題。

これを解消するためには、2つの state を合体させて2つの state にする必要がある。

このような状況に適しているのがuseReducer

useReducerによる解決

前述のコードをuseReducerを利用して書き換えると以下のようになる。

import React, { useReducer } from "react";

const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0);

const reducer = (state, action) => {
  switch (action.type) {
    case "input": {
      const newValues = [...state.values];
      newValues[action.index] = action.value;
      return {
        ...state,
        values: newValues
      };
    }
    case "check": {
      const total = sum(state.values);
      const ratio = Number(state.values[action.index]) / total;
      return {
        ...state,
        message: `${state.values[action.index]}は${total}の${(
          ratio * 100
        ).toFixed(1)}%です`
      };
    }
    default: {
      return state;
    }
  }
};

const NumberInput = React.memo(({ value, index, dispatch }) => {
  console.log("render NumberInput");
  return (
    <p>
      <input
        type="number"
        value={value}
        onChange={e =>
          dispatch({
            type: "input",
            index,
            value: e.currentTarget.value
          })
        }
      />
      <button
        onClick={() =>
          dispatch({
            type: "check",
            index
          })
        }
      >
        check
      </button>
    </p>
  );
});

export default function App() {
  const [{ values, message }, dispatch] = useReducer(reducer, {
    values: ["0", "0", "0", "0"],
    message: ""
  });

  return (
    <>
      {values.map((value, i) => {
        return (
          <NumberInput key={i} index={i} value={value} dispatch={dispatch} />
        );
      })}
      <p>合計は{sum(values)}</p>
      <p>{message}</p>
    </>
  );
}

前述のonChangeonCheckが担っていたロジックはreducer内のロジックに置き換わった(reducerが担うことになった)。

そのため、NumberInputにはdispatchを渡すだけになった。

また、useReducerを利用することで、2つの state が1つの reducer に集約された。

つまり、「valuesを見てmessageを決める」という計算が、「今の state から次の state を計算する」という枠組み(reducer)の中に入ったということになる。

結果として、dispatchは state がどのような変更をされるのかは知らなくて済むようになった(アクションを送るだけで良くなった)。

dispatchは state に非依存の関数なので、前述のonCheckのようにvaluesが更新されても関数は再生成されなくなった。

そして、再生成されないdispatchを渡すことにより、React.memoによる再レンダリングを防ぐことができるようになった。

ポイントの整理(今回行ったことを振り返る)

今回の最も重要だったことは「state の更新関数を state に非依存にする」ことである。

useState で state の更新関数を state に非依存にするためには

前述のuseStateを利用したコードでは、onCheckという関数が state(values)に依存している関数だったので、再レンダリングを防げなかった。

state の更新関数を state に非依存にするには、「現在の state を受け取って次の state を計算させる」必要がある。

useStateでこれを実現するには、state 更新関数に関数を渡す必要がある。

そのため、、以下のようにするのではなく、

setValues(newValues);

以下のようにする必要がある。

setValues(currentValues => {...; return newValues });

前述のonChangeではこれができていたが、onCheckではできなかったので、再レンダリングを防げなかった。

useReducer で何をしたのか

これを改善するために、useReducerを利用して「2 つの state(valuesmessage)を 1 つに合体させる」ことを行った。

結果として、onCheckでも、state に依存しない関数(dispatch関数)による state 更新ができるようになった。

頑張ればuseStateでも同じようなことはできるが、このような複雑な state を扱うにはuseReducerが適しているので、useReducerを選択することになる。

ということで、useReducerを利用すれば、state 更新関数(dispatch)は自動的に state に非依存になる。

useReducerのすすめ

このように、useReducerを用いることで、state 更新関数を state 非依存にすることを強制できる

実際のアプリ開発においては、以下のような状況になる可能性がある。

  • アプリが複雑化するにつれて、ある state と別の state が関わりを持ち始める。
  • ある state を更新するときに別の state を見る必要が発生する。

このような状況になったら、useReducer導入のサインなので、リファクタリングしてuseReducerを導入する。

なぜuseReducerが必要なのか

  • useReducerを利用することで、state 更新関数が state に非依存になるから
  • state 更新関数が state に非依存になることで、React.memoを活用しやすくなるから

useReducerReact.memoの恩恵を最大限受けるためには

useReducerReact.memoの恩恵を最大限受けるためにはできるだけreducerにロジックを詰め込むことが重要。

reducerにロジックを詰め込むには、アプリの状態は何でも state で表現する必要がある。

そのため、state は明示的・宣言的に扱う(手続き的なロジックは書かない)ことが重要である。

また、useReducerを活かすためには、そのためのコンポーネント設計も重要。

今回、useReducerを活かすために、NumberInputindexを props で受け取るようにした

なぜこのようにしたのかと言うと、dispatchを呼び出して自分のvalueを更新するためには、自分が何番目かをdispatchに教える必要があるから。

このように、useReducerを活かすためにはコンポーネント設計も重要になる。

副作用はどうするのか?

今回の例では、useReducerによって state をひとつにまとめることで、onCheckコールバックを state に非依存にすることができた。

もし、「check」ボタンを押すと起こることが、何らかの副作用(HTTP リクエストが発生するなど)だったらどうするのか。

現時点では、副作用は reducer の中に書くべきではないという原則があるので、この記事で使った手を使うことはできないし、現時点では対処法はない

副作用をどこかのコールバック関数に書いた時点で、その関数が state に依存することとなり、React.memoによるパフォーマンス改善の妨げとなる。

Redux の本質

上記の問題を解消する手段の1つが Redux である。

Redux を用いた state 管理の場合、Redux ミドルウェアの活用によって、state に依存する副作用ですらdispatchの中に押し込めることができる。

つまり、副作用ですら state に非依存させることができる。

Redux の本質は React のツリーの外で state を管理してくれることであり、それによりReact 本体のみでは困難な state 非依存性が実現している。

Redux はただ state 管理に関する統一的な方法論を与えるだけでなく、このようなパフォーマンス上のメリットもあるということは覚えておいて損はない。

React 17.x 系の展望

しかし、React 17.x 系(いわゆるConcurrent Modeが導入されると期待されています)ではまた情勢が変わる可能性がある。

端的に言えば、Concurrent Mode においては(主に非同期的な)副作用ですら state 内で管理されるようになり、そのための道具が Suspense である。

Concurrent Mode では副作用と state 管理の概念が大きく様変わりし、Redux などに頼らずともパフォーマンス的に最適な副作用の扱いが達成できる場面が増えると予期される。

まとめ

この記事では、コールバック関数が state に依存する場合に、React.memoの恩恵を受けられないという問題に対してuseReducerを用いて対処する方法を示した。

ポイントはstate 更新関数を state 非依存にすることであり、(useStateでもそれは可能なものの)useReducerはそのような書き方に適している。

記事冒頭のまとめを再掲。

  • useReducerは、state に依存するロジックを、state に非依存な関数オブジェクト(dispatch)で表現できる。
  • state に依存するロジックを、state に非依存な関数オブジェクトで表現できることにより、React.memoによるパフォーマンス改善を行える。
  • useReducerを活かすには、state を1つにまとめることで、ロジックをなるべく reducer に詰め込む。

useReducerの存在価値は単に Redux と同じ書き方ができるというだけではなく、この記事で説明したような本質的な問題を解決するために利用できる。