129
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useStateはなぜ値を保持できるのかもう1回考える

Last updated at Posted at 2023-12-02

はじめに

こんにちは。
株式会社HRBrainでフロントエンジニアをしている塚本です。

この記事はHRBrain Advent Calendar 2023の3日目の記事です。

useStateってずっと使っていて、なんとなく使えてしまっているが、
改めて考えるとどのような原理で動いているフックなのかよくわかっていないな〜と思ったので調べてみました。

この記事で書くこと

  • レキシカルスコープとダイナミックスコープ
  • クロージャー
  • ガベージコレクション
  • Reactのhooksの実装の簡単な内容

この記事で書かないこと

  • fiberなどを始めとする、Reactの詳細な実装内容

公式のドキュメントに書いてあること

まずは公式のドキュメントを見てみましょう。

公式ではuseStateをどのように説明しているのでしょうか?

useState は、コンポーネントに state 変数 を追加するための React フックです。

なるほど。わかる気がします。ではstateとは何なのでしょうか。

コンポーネントによっては、ユーザ操作の結果として画面上の表示内容を変更する必要があります。フォーム上でタイプすると入力欄が更新される、画像カルーセルで「次」をクリックすると表示される画像が変わる、「購入」をクリックすると買い物かごに商品が入る、といったものです。コンポーネントは、現在の入力値、現在の画像、ショッピングカートの状態といったものを「覚えておく」必要があります。React では、このようなコンポーネント固有のメモリのことを state と呼びます。

「何をしたいのか?」についてはとてもわかり易く、理解できる気がしてきました。
ですがやはり、この「覚えておく」ってどうやっているの?ということが気になります。
再レンダリングされた際に、どうして前回値であるはずのstateを「覚えておき」、もう一度呼び出すことができるのでしょうか。調べてみました。

useStateを理解するために知っておくといいこと

調べた結果、useStateを理解するためには、

  • レキシカルスコープ
  • クロージャー
  • ガベージコレクション

について主に知っておくと良いとわかりました。1つずつ見てみましょう。

レキシカルスコープ(lexical scope)

別名、静的スコープとも呼びます、
文字通り、字句(lexical)を最小単位へと分解・解析(lexsing)した時にスコープを決める、という方針のスコープです。
JavaScriptではレキシカルスコープを採用しています。

また、対立的な概念として、ダイナミックスコープ(動的スコープ)が存在しますが、
こちらは実行時に子から一番近い親ブロックのスコープを参照します。
採用例としてはEmacs Lispが挙げられます。

なぜ、useStateを理解するために、レキシカルスコープを理解する必要があるかというと、次で話すクロージャーに関わるからです。

クロージャー

クロージャは関数とその関数が作られた環境という 2 つのものの組み合わせです。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Closures#%E3%82%AF%E3%83%AD%E3%83%BC%E3%82%B8%E3%83%A3

クロージャー関数は、作成された時点での内部スコープを参照することができます。
この内部スコープを参照できるのは、レキシカルスコープが前提に存在するからです。
ダイナミックスコープでは、一番近い親ブロックのスコープを参照してしまうため、クロージャー関数が宣言されたスコープを参照できる保証がありません。
レキシカルスコープが前提にあるからこそ、クロージャー関数は利用時に、自分が宣言されたスコープの内部を参照することができるのです。

また、クロージャー関数は実行後も内部の値を参照し続けることができます。
なぜ参照し続けることができるかというと、JavaScriptがガベージコレクションをメモリ管理戦略として採用しているからです。

ガベージコレクション

メモリリークを防ぐためにJavaScriptで採用されているメモリ管理方法です。
具体的には、割り当てられたメモリが必要なくなった場合に自動的に開放する、という方針です。
「必要なくなった時」というのは、値が参照されているかどうか?に基づいて判断されます。
どこからも参照されなくなった値は、ガベージコレクションの対象となり、値に割り当てられたメモリを開放します。

どうやってuseStateで利用されているの?

では、上記で見てきた概念はどのようにuseStateで利用されているのでしょうか。

まず、useStateで利用したいstateは、再レンダリングされるより外側のスコープに宣言される必要があります。
useStateは実行されると、クロージャー関数であるため、そのstateを参照しに行くことができます。
このstateは、useStateに参照されている間はガベージコレクションの対象にならないため、メモリが保持され続けます。
このようにして、stateはレンダリングの外側のスコープで保持され続け、結果として「覚えておく」といった挙動が叶います。

実際のuseStateの実装はどうなっているのか

説明としては上記で理屈が通りますが、本当にそのような実装になっているのでしょうか?
Reactのコードをさらっと確認してみましょう。

ReactのuseStateの実装は以下です。

ReactHooks.js
export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

これだけだとよくわかりませんね。

まず普段useStateを使うにあたって、意識している部分である引数と返り値から見てみます。
引数はinitialStateで、型は(() => S)またはSです。
useStateが初めて呼び出されたときに提供される初期状態に相当します。
個人的にはSを渡すことが多いですが、(() => S)も取れるので、関数としても提供することができます。

返り値は、2要素からなる配列[S, Dispatch<BasicStateAction<S>>] です。
配列要素はそれぞれ、1番目が現在の状態を表す変数(S型)、2番目は関数のようです。
ですが、Dispatch<BasicStateAction<S>>が具体的にどのような実装かわからなかったので、定義元を見てみました。

ReactHooks.js
type BasicStateAction<S> = (S => S) | S;
type Dispatch<A> = A => void;

つまり、(S => S)型で状態の更新を行う関数か、またはS型で状態そのもののがBasicStateAction<S>型のようです。
Dispatch<A>は、Aを受け取って何も返さない関数の型のようですね。
これらを組み合わせると、 Dispatch<BasicStateAction<S>>は((S => S) | S) => voidになり、やっと見慣れたuseStateの型になりました。

中身を見てみましょう。
dispatcherが何をしているか知るために、resolveDispatcherを確認しにいきます。

ReactHooks.js
function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
    // 省略
  return ((dispatcher: any): Dispatcher);
}

ReactCurrentDispatcherの中を見ると出てくる、react-reconciler、fiberなどについては複雑になりすぎるのでここでは割愛します。
ReactCurrentDispatcherの実態は以下です。

ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  // 省略
    ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }
  // 省略

このcurrent.memoizedStateはDispatcher型をとりますが、これはFiberアーキテクチャの一部であり、ここでstateを管理しているようでした。
これを踏まえてコードを見ると、useStateの初回呼び出しはHooksDispatcherOnMount、2回目以降はHooksDispatcherOnUpdateが呼ばれているようです。

では、HooksDispatcherOnMountとHooksDispatcherOnUpdateを見てみましょう。これらの実態は、同じファイルのmountStateとupdateStateです。

ReactFiberHooks.js
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState);
}

mountStateでは、渡されたinitialStateをhook.memoizedStateに入れています。返り値は、initialValueと、更新用のfiberに紐づいた関数dispatchです。
updateStateは、updateReducerを返しますがここでは省略させてください。最終的に返すのは[今の値、更新用関数]です。

簡単なuseStateを自分で作ってみた

ここまでで、useStateの正体は大分理解できました。
ということで、自前のuseStateで実装してみて、クリックするとcountが増えていく、という簡単なボタンを作ってみます。

まず、App.tsxとindex.tsxを用意します。

App.tsx
import { FC } from 'react';
import { useMyState } from './useMyState2';

import './style.css';

export const App: FC = () => {
  let count = 0

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => count + 1}>Click Me</button>
    </div>
  );
};

index.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

import { App } from './App';

export const root = createRoot(document.getElementById('app'));

root.render(
  <StrictMode>
    <App name="StackBlitz" />
  </StrictMode>
);

これだと、以下のようにうまくいきません。

umakuikanai_button.gif

うまくcountが増えていかない理由は、

  1. countを押しても、再レンダリングされない
  2. let count = 0によって、再レンダリングされたとしてもcountは0のままになってしまう

ではここでいよいよ、自前のuseStateであるuseMyStateを追加してみましょう。1

useMyState.tsx
// 簡単のためここではグローバルスコープにstateを宣言する
let state: unknown;

export function useMyState<T>(defaultValue: T): [T, (newState: T) => void] {
  // useMyStateを初めて呼ぶ時の処理
  if (state === undefined) {
    state = defaultValue;
  }
  const setState = (newState: T) => {
    state = newState;
  };
  return [state as T, setState];
}

実装したuseMyState.tsxをApp.tsxで使用してみます。

App.tsx
import { FC } from 'react';
import { useMyState } from './useMyState';

import './style.css';

export const App: FC = () => {
  const [count, setCount] = useMyState(0);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>Click Me</button>
    </div>
  );
};

これもうまくいきません。

umakuikanai_button2.gif

先程うまくいかなかった理由の「2. let count = 0によって、再レンダリングされたとしてもcountは0のままになってしまう」は解消しましたが、「1. countを押しても、再レンダリングされない」が解消していないので、stateが変わってもUIのcountを変えることができません。

では、無理やり再レンダリングしてみましょう。2

useMyState.tsx
import { root } from '.';
import { StrictMode } from 'react';
import { App } from './App';

let state: unknown;

export function useMyState<T>(defaultValue: T): [T, (newState: T) => void] {
  if (state === undefined) {
    state = defaultValue;
  }
  const setState = (newState: T) => {
    state = newState;
    // setState後に無理やり再レンダリングさせる
    root.render(
      <StrictMode>
        <App name="StackBlitz" />
      </StrictMode>
    );
  };
  return [state as T, setState];
}

umakuiku_button1.gif

うまく動くようになりましたね。
こうして見ると、意外とシンプルに実装できました。

おまけ

先述した、useMyStateでは、複数のstateに対応できません。
そこで、実際のuseStateのように複数のstateにも対応してみました。3

useMyState2.tsx
import { root } from '.';
import { StrictMode } from 'react';
import { App } from './App';

let states: [unknown, (newValue: unknown) => void][] = [];
let currentIndex: number = 0;

export function useMyState<T>(defaultValue: T): [T, (newState: T) => void] {
  if (states.length === currentIndex) {
    const pair = [
      defaultValue,
      (newState: T) => {
        pair[0] = newState;
        currentIndex = 0;
        root.render(
          <StrictMode>
            <App name="StackBlitz" />
          </StrictMode>
        );
      },
    ];
    states.push(pair as [unknown, (newValue: unknown) => void]);
  }

  const returnValue = states[currentIndex] as [T, (newState: T) => void];
  currentIndex += 1;
  return returnValue;
}

App.tsx
import { FC } from 'react';
import { useMyState } from './useMyState2';

import './style.css';

export const App: FC<{ name: string }> = ({ name }) => {
  const [count1, setCount1] = useMyState(0);
  const [count2, setCount2] = useMyState(0);

  return (
    <div>
      <h1>Count1: {count1}</h1>
      <button onClick={() => setCount1(count1 + 1)}>Click Me</button>
        //ボタンを2つにしただけ
      <h1>Count2: {count2}</h1>
      <button onClick={() => setCount2(count2 + 1)}>Click Me</button>
    </div>
  );
};

umakuiku_button2.gif

stateを複数持てるように、statesというArrayを宣言しました。
新しいstateを追加した場合は、states.length === currentIndexのif文に入り、新たなstateと、setStateの組み合わせであるpairがstatesに追加されます。
useStateを定義しているのはif文の中ですが、states自体はグローバルスコープに存在するため、statesに内包されるsetStateをApp.tsxから呼び出すことができます。

感想

OSSの実装を読む、ということをあまりじっくりやったことがなかったのでいい体験になりました。
useMyStateは意外とシンプルに実装できましたが、やはりReactのコードは難しかったなというのが素直な所感でした。
ただ、なんとなく使えてしまっている技術も、裏側で何が起こっているかを把握すると、周辺概念との辻褄があって理解が進むな〜という感じでした。
ちなみに、useStateの実際の実装を読んでいて、HooksはLinkedListの形で管理されていますという記述を見つけました。
Rule of Hooksから分かる通り、コンディショナルなhooksは許されず、hooksは毎回同じ順番で呼び出される必要があるのはindexでアクセスしているからだ、という認識はあったのですが、実際にその記述をコード内に見つけて嬉しくなりました。

最後に

最後にお決まりですが、株式会社HRBrainでは新しいメンバーを募集中です。

参考文献

https://uraway.hatenablog.com/entry/2018/01/24/120000
https://sbfl.net/blog/2019/02/09/react-hooks-usestate/
https://www.codementor.io/@lizraeli/implementing-the-usestate-hook-13o3wjkh3j

  1. ここでは、簡単のためにundefinedをstateとして扱わない前提で実装しています。

  2. ここでは、useStateを自前で実装する、という趣旨のもとこのようにrender()を呼んでいますが、Reactでは本来このようなことは行いません。

  3. 実はこの実装だと、currentIndexは2ずつインクリメントされてしまいます。おそらく無理やり再レンダリングさせている関係で2回更新されてしまうのだと思うのですが、詳細な原因はわかりませんでした。もしよりよい解決法をご存知でしたらご教授いただけると嬉しいです。

129
73
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
129
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?