📏

React の変数・状態をライフタイムで分ける――アクション/レンダー/コンポーネント/ファイル

2023/08/03に公開

yoshiko さんが提唱された 『React のステート 3 分類』 すなわち 「1. サーバーデータのキャッシュ, 2. Global State, 3. Local State」の 3 種類に分ける 考え方は、みなさんご存じかと思います。

https://zenn.dev/yoshiko/articles/607ec0c9b0408d

この分類をさらに一般化かつ微細化して、React で登場する変数(または const, 引数)および状態が、それぞれ異なった長さのライフタイムを持っている、と捉えなおすことで、満たしたい仕様をシンプルな方法で、かつ精密に実現するコードを書けるようになります。

ライフタイムは大まかに 4 種類に分けられます。短→長(すなわちスコープの狭→広の順)に並べると、以下のようになります。

  1. アクション
    • ライフタイムは最も短い・スコープは最も狭い
    • 「クリック」など、特定のアクションによる処理の間だけ
    • 一度っきりの一時変数
  2. レンダー
    • React コンポーネントのレンダリング中の一時変数
  3. コンポーネント
    • useState / useRef
    • コンポーネントの生成から破棄まで生き残る
    • 子よりも親のほうが長い
  4. ファイル
    • ライフタイムは最も長い・スコープは最も広い
    • コンポーネントの生成・破棄・レンダリングから独立
    • 定数(イミュータブル)、React 外部の状態(ミュータブル)

a. 各ライフタイム

1. アクション ―― 保存されず一度っきりの一時変数

// <input
  onChange={(event) => {
    const value = event.currentTarget.value;
    console.log(`value: ${value}`);
  }}

アクション は、 useEffect の引数に渡したエフェクト関数や、 on〇〇 のようなイベントハンドラの関数を実行中に保持される一時変数、そしてそれと同等な引数が含まれます。

上のコードの event, value がここに当てはまります。

わざわざ保存する必要がないデータであれば、3. コンポーネントすなわち useState や useRef の利用を避けるべきです。コンポーネントの保持すべき状態を最低限に抑えると、コードのメンテナンス性が向上します。

2. レンダー ―― 再レンダリングのたびに再計算される

レンダー の区分には、次の2つが当てはまります。

  • レンダリングの途中の一時変数、または写像
  • 親から渡された Props

2-1. レンダリングの途中の一時変数(派生した状態)

/* 
const SomeComp: FC = () => {
  const [count, setCount] = useState(0); */
  const double = count * 2;

React は、初回および再レンダリングの際に、コンポーネントの関数を呼び出します。その中の式は上から下まで順に実行されます。

なので、上記の double の値は、いつでも count の値の 2 倍になります。

ステートが変更されると再レンダリングが発生し、前回のレンダリング時に計算した古い値の入った変数は箱ごと破棄され、新しい箱としての変数に新しい値が代入されます。 (なので、 let 変数にして再代入するのは、ほとんど意味を成しません。)

このような挙動を使って UI の整合性をキッチリと守る というのが、 React の関数コンポーネントの根底にある "State as a Snapshot" (ステートはスナップショットである) という考え方です。

useMemo, useCallback は、この「レンダー」のライフタイムに関係があります。依存配列の各要素すべてが一致しているか判定することで「前回のレンダー時の値(あるいは関数の参照)」を使い回せるかどうかチェックして、OK であれば使い回します。

https://qiita.com/honey32/items/ee8d1577e68b0d58678d

https://ja.react.dev/learn/state-as-a-snapshot

2-2. 親から渡された Props

export const CountDisplay: FC<Props> = ({
  count
}) => {
  return <div>{count}</div>
}

Props も同じく「レンダー」のライフタイムです。親から 2. レンダー ,3: コンポーネント,4: ファイル のいずれかの値が渡されます。

UI の整合性を守る必要があるため、それらの内で最も短い 2. レンダー として取り扱うべきです。(eslint もその前提でチェックしているはず)

3. コンポーネント ―― useState で保持される状態

/* 
export const SomeForm: FC<Props> = ({
  initialValues 
}) => { */
  const [formManager] = useState(() => new FormManager());
  const [name, setName] = useState(initialValues.name);

useState で管理しているコンポーネントの状態 がこれに該当します。再レンダリングと全く無関係なデータの保持には useRef も使われます。

コンポーネントがマウントされるとき(つまり、初回レンダリング)には 初期値がセットされ、それ以降は、

  • セッター関数を呼び出すと、更新される
  • 更新されなかった場合は、前回と同じ値が使われる

という変化を辿って、最後にはコンポーネントが破棄されるときに忘却されます。

useEffect はここでも不要かもしれない

useEffect(() => {/* 初期化処理 */}, []) と書くとコンポーネントの初期化処理を指定できる」というのはよくある勘違いですが、たいていの初期化処理は、「useState() の引数を使う」ことで実現できます。

また、オブジェクトを new して保持し、コンポーネントが破棄されたら役目を終える、というような使い方も可能です。(4. ファイル と似ている)

useEffect が必要なケース
  • イベントリスナの登録など、「React の外」とのやりとり
    • これは useEffect, useSyncExternalStore の典型的なユースケース
  • (上とも近いが) 非同期にデータ取得する処理
    • react-query, swr などを使ってボイラープレートを隠蔽できる
    • Suspense, React Server Component のような新しめの機能を使う
  • その他
    • Next.js pages router (旧来のルーター、App じゃないほう) の router.isReady を使わないといけないケース

4. ファイル ―― コンポーネントよりも前から存在し、最も長生きする

import { atom } from "jotai";
const currentUserIdAtom = atom<string | undefined>(undefined);

コンポーネントの外側で定義した ものが、これに該当します。

わかりやすい例としては、以下のようなものがあります。

  • コンポーネントの関数定義そのもの
  • コンポーネントの外で定義した reducer など
  • atom (Recoil, Jotai)
  • ミュータブルな外部ストア
    • Redux, Recoil, Jotai, TanStack Query などが、このように状態・キャッシュを保持している

コンポーネントの外に書いた変数は、 ファイルが import されたときに一度だけ評価 され、そのまま定着し、ブラウザを閉じる or リロードされて初めて破棄されます。

React においては、 オブジェクトの実体・参照が新しいものに差し替わると困る、というケースに多く出会います。そのような場合に 1. アクション 2.レンダー を使うのは、頻繁に新しくオブジェクトを生成してしまうので不適です。そのため、

  • コンポーネントと同じライフタイムであってほしい場合
    • (親から渡された Props で規定されたいモノなど)
    • 3. コンポーネント の useRef / useState
  • ずっと生き残っても構わない場合
    • 4. ファイル

をそれぞれ使う場合があります。


以上が 4 つのライフタイムによる分類でした。

次章からは、ライフタイムのさらに細かい分け方、それを活用した考え方と具体的なテクニックについて見ていきましょう。

b. 「長い」親コンポーネントと「短い」子コンポーネント

TodoTaskListTodoTask を直接利用している、あるいは Context によって間接的に連携しているとき、 TodoTaskList のステートは TodoTask のそれより長いライフタイムを持っている と考えることができます。

const TodoTaskList: FC = () => {
  const [tasks, setTasks] = useState(/* 中略 */);
  const mutateDone = (id: string) => {
    setTasks(prev => /* 中略 */);
  }

  return (
    <div>
      {tasks.map((task) => (
        <TodoTask 
          key={task.id}
          title={task.title}
          onDone={() => {
            mutateDone(task.id)
          }}
        />
      ))}
    </div>
  );
}

🚫 TodoTaskList (親)から TodoTask (子)の状態を 手続き的に編集する ことは不可能で、Props を通じて連動させることしかできません。

✅ いっぽうで、TodoTask (子)から TodoTaskList (親)の状態を 手続き的に編集 することはコールバック Props (例: onDone)のお陰で可能です。

この関係は、 次章で述べる c. 依存の一方向性 と似ているので、親のステートは子のそれよりも長いライフタイムを持っている と考えると、応用が効くと思います。

c. フック内は、コンポーネント内と同等に考える

フックの内側の記述に関しても、コンポーネント内と同じようにライフタイムを考えることが出来ます。

export const useStopWatch = (name: string /* 2. レンダー */) => {
  const [hoge, setHoge] = useState(""); // 3. コンポーネント

  const handleHoge = useCallback(() => {
    const now = Date.now(); // 1. アクション
  }, []);

また、コンポーネントの親子関係と同様に、 useHoge を使用するフックおよびコンポーネントのステートは、useHoge よりも長いライフタイムを持っている と考えることができます。

d. 依存の一方向性

1. アクション2. レンダー3. コンポーネント4. ファイル の順、つまり、自分と同じまたはより大きな番号の(つまり自分と同じまたは長い)ライフタイムの変数にアクセスし、情報を取得、手続き的に変更することは可能です。

🚫 しかし、逆の向きについては、情報を取得・変更することは一部の例外を除いて不可能です。

ただし、いくつかの特記事項を挙げておきます

d-1. レンダー結果への影響は state / ref を経由 (🚫 1 ⇒ 2) (✅ 1 ⇒ 3 ← 2)

export const SomeComp: FC = () => {
  let name = "a";
  // ...
  // <input
    onClick={(event) => {
      const value = event.currentTarget.value
      // 🚫 let 変数を書き換えない
      name = value;
    }}
}

例外として、 1. アクション から直接 2. レンダー の位置にある let 変数を書き換えるのは可能ですが、意味を成しません。このような let 変数は 「再レンダリングのたびに変数という箱そのものが破棄→再生成される」からです。

なので、何らかのアクションによって、UI の表示内容に影響を与えたいのなら、 state / ref への変更を加える必要があります。

d-2. useState の初期値と key によるリセット (3 → 2)

「ステートは、親から渡された Props を初期値に使うことができる」 これが 3. コンポーネント2. レンダー の向きに、値を取得するパターンです。

あまり多くの人の知るところでないテクニックですが、

key に渡される selected の値が変わるたびに、

UserNameEdit を「破棄(アンマウント)→ 再マウント」して、その name ステートが(新しい selectedUser の値で)初期化される

という形式で実現することができます。

詳しくはこちらの記事にまとめています

https://zenn.dev/yumemi_inc/articles/react-initial-state-take-advantage

const [selectedUser, setSelectedUser] = useState<string>();

selectedUser && (
  <UserNameEdit key={selectedUser} defaultName={selectedUser} />
)
type Props = { defaultName: string }

const UserNameEdit: FC<Props> = ({ defaultName }) => {
  const [name, setName] = useState(defaultName);
  // 以下略
};
🤔 useState の引数に渡された値が変わるとステートが変わるんじゃないの?

あれ?ユーザーを選択したら userName Props が変わるのに、 UserNameEditname ステートの値が変わってくれない!!

というミスは初心者の方にありがちだと思います。

React のステートの挙動を正確に説明すると✅「初回レンダリング時(つまりマウント時)のみ 、引数に渡された値で初期化される」です。

その「マウント時」を宣言的な方法で意識的に引き起こすために key が使えるのです。

このような働きをする Props には defaultName とか initialName のような名前を付ける のが定石 (例: <input>defaultValue Prop) なので、これに従うのが GOOD です。

ほかの Props とはまったく違う働きをしているので、慣例に従って察しやすい名前にすることが、「コンポーネントを使う側」に対する 驚き最小の原則 だと思います。

▼ 詳しい解説はこちら(「オプション 2:key で state をリセットする」の章)

https://ja.react.dev/learn/preserving-and-resetting-state#resetting-a-form-with-a-key

d-3. ステートがリセット・保持されるその他の条件

レンダーされる/されないが切り替わったり、ツリーの位置が変わったり、によっても新しいインスタンスが作られたり破棄される場合があります。

このときにもステートは初期化されます。

これについては、公式ドキュメントが詳しいので、説明を省略します。

https://ja.react.dev/learn/preserving-and-resetting-state#

d-4. useEffect, useSyncExternalStore でサブスクライブ (4 ⇒ 3)

const store = new MyLibStore();

const useMyLibHogeValue = () => {
  return useSyncExternalStore(
    () => store.subscribe(),
    () => store.hogeValue,
    () => undefined
  );
}

useEffect や、React 18 から導入された useSyncExternalStore を使うと、 外部のストアの変更を購読する ことができます。

  • Recoil (2023/08/03 現在 useSync...)
  • Jotai (2023/08/03 現在 useEffect)

例えば上のような状態管理ライブラリは、コンポーネントの外 (4. ファイル) や親コンポーネント (長い 3. コンポーネント)にあるミュータブルなオブジェクト で状態を管理し、これらのフックを使ってその状態の一部を購読できるような作りになっています。

一つの context で大きな状態のオブジェクトを管理したときには無駄な再レンダリングが発生してしまう(↓のリンクを参照)ので、それを回避する方法として上の方法が使われています。

https://blog.uhy.ooo/entry/2021-07-24/react-state-management/#usecontextの使用

これは、コンポーネント内から 4. ファイル の値を取得してステートのように扱うことができますが、視点を変えると、 4. ファイル の位置から3. コンポーネント 以下つまり コンポーネントの状態を変更する 唯一(or 唯二?)の方法である、と考えることができそうです。


以上が「依存の一方向性」に関する、特記事項と例外でした。

e. (3)コンポーネントを避けて(2)レンダーに

(3) コンポーネント のうち、特にステートは、数が多いほど コンポーネントの状態管理を複雑化 させます。

なるべく ステートの重複 (redundant state) を避けるように心がけましょう。そうすることで、バグの発生リスクや、コードを理解するための労力を低く抑えることができます。

以下のコード例を見てみましょう。

  const [familyName, setFamilyName] = useState('');
  const [givenName, setGivenName] = useState('');
- const [fullName, setFullName] = useState(
-    `${familyName} ${givenName}`
- );
- useEffect(() => {
-   setfullName(`${familyName} ${givenName}`);
- }, [familyName, givenName]);
+ // 再レンダリングのたびに計算
+ const fullName = `${familyName} ${givenName}`

「フルネームは、苗字と下の名前を繋げたもの」というときには、フルネームをステートにして同期を取る必要はありません。

代わりに、 const fullName = ... のような (2) レンダーの一時変数を使うと、再レンダーのたびに値が計算される ので、「フルネームは、常に、苗字と下の名前を繋げたものになる派生状態である」という動作を実現できます。

https://ja.react.dev/learn/choosing-the-state-structure#avoid-redundant-state

f. (3)コンポーネント/(2)レンダー、つまり「リアクティブな値」は最小限にとどめる

リアクティブな値は、「再レンダリングのときに変わりうる値」であり、 useMamo, useCallback, useEffect で使われるリアクティブな値は、すべて依存配列に含める必要があります。 (useState の更新関数と、 ref は対象外です。)

下に述べる通り、 「関数で使われているけど、依存配列に含めない」という嘘をつくと、途端にバグ発生のリスクが上昇する からです。

エフェクトの実行、メモ化のコスト、コード理解の困難さを低減したいが、依存配列で嘘は付きたくない場合には、依存配列に含める必要がない、つまり「リアクティブじゃない値」にできるだけ変更するのが得策です。

特に useEffect の依存配列は、頻繁には実行したくない重い処理が走りがち(最悪の場合、無限ループが発生することもある)なので、依存配列を減らしたくなることがあると思います。

https://ja.react.dev/learn/removing-effect-dependencies

この公式ドキュメント記事は、

  • 依存値にする必要がない、と明示する。 ← ✅
    • リアクティブでない(1)アクション・(4)ファイルに直す
  • 現在の値に依存しない書き方を使う
    • setState(prev => f(prev)) を使う
    • useEffectEvent() (実験的な機能) を使う

という方法が挙げられていますが、とくに前者(✅ で強調したほう)のほうは変数のライフタイムと関係があるので、ここで触れてみます。

f-1. (1)アクションに変える

AbortController を使わずに、イベントリスナーを追加するコードを考えてみましょう。

ここで、依存配列を改善する方法の一つは、handleClickuseEffect のエフェクト関数の中に移動する、つまり 2.レンダー から 1. アクション に移す ことです。

- const handleClick = useCallback(() => {
-   console.log("clicked!!!");
- }, []);

  useEffect(() => {
+   const handleClick = () => {
+     console.log("clicked!!!");
+   };

    window.addEventListener('click', handleClick);
    return () => {
      window.removeEventListener('click', handleClick);
    }
- }, [handleClick]);
+ }, []);

f-2. (4)ファイルに変える

2. レンダー の値は、最悪の場合、再レンダリングのたびに再計算が起こります。(それを避けるためには、 useMemo を使わないといけない。)

またコードの保守性を高めるために、「変わらない性質」と「現在の状態」に分ける方法もありえると思います。(Recoil, Jotai の Atom Config も同じような感じかな?)


useEffect 等の依存配列からは話が逸れますが、再レンダリングでパフォーマンスに悪影響がある身近な例として、 emotion を使ったスタイルの動的切り替えを題材として見てみましょう。

https://emotion.sh/docs/best-practices#consider-defining-styles-outside-your-components

emotion を使用して、しかも再レンダリングのたびにスタイルが切り替わるような動的な記述をすると負荷が大きいらしく、 emotion 公式ドキュメントでは、以下のような書き方が推奨されています。

  • 「変わらない部分」
    • emotion で書く
    • コンポーネント内ではなく、ファイル直下に書く
      • 4. ファイル
  • 「変わる部分」
    • style 属性を書く(書き換えが軽いらしい)
      • 必要に応じて、CSS 変数を使う
      • data- 属性も使えるかも
// emotion で指定する共通のスタイル
const styles = {
  root: css({
    display: "block",
    padding: "8px",
    backgroundColor: "var(--button-color)",
  })
};

// style 属性に入れる色
const colorPalette = {
  primary: "blue",
  secondary: "gray",
  danger: "red",
};

type Color = keyof typeof colorPalette

export const Button: FC<{ color: Color }> = ({ color }) => {
  return (
    <button 
      css={styles.css}
      style={{ `--button-color`: colorPalette[color] }}
      {...otherProps}
    />
  );
};

g. atom のライフタイムを (4) よりも短くする Atoms in atom と Jotai Molecule

Atom Config は通常は 4. ファイル レベルの定数ですが、

  • 「親から渡された Props の値を初期値に使いたい」
    • (この使い方は React Server Component や SSR と相性が良さそう)
  • 「ステートを共有したくないけど Atom を使いたい」

というケースにおいて、 3 以下を読み取ることが出来ないという 4. ファイル ならではのデメリットがあり、ライフタイムをあえて短くしたいニーズがあります。

そのような場合には、 Atoms in atom (または Storing atom config in useState etc.) というテクニックがあります。

https://jotai.org/docs/guides/atoms-in-atom

また、これを更に進めて、(モナド?っぽく Molecule という文脈に包んで) 扱いやすくしてくれるライブラリとして、 Jotai Molecules という Jotai 非公式のライブラリがあります。

https://jotai.org/docs/integrations/molecules

おわりに

いかがだったでしょうか?

変数・状態等の依存関係を「ライフタイム」という視点で捉える ことによって、 やりたいことを的確に実装することが可能になることが分かったと思います。

実装していて何かしっくりこないときには、ライフタイムの視点でコードを再検討してみましょう。

株式会社ゆめみ

Discussion