LoginSignup
52
29

ReactのuseStateについて丁寧に理解する

Last updated at Posted at 2023-03-10

はじめに

useStateはReactの関数コンポーネントで使われる代表的なAPIの1つで、シンプルかつ便利なのでよく使われる馴染み深い機能です。この記事ではそんなuseStateについて踏み込んで解説します。
この記事で述べるコンポーネントとは基本的にReactの関数コンポーネントを指します。

状態

useState自体がどのような振る舞いを持つAPIなのかを紹介する前に、useStateを学ぶ時に必要となる状態(State)という概念を紹介します。
状態とはコンポーネントのレンダリング間で保持され、変更することで再レンダリングが起きるような値のことを指します。言い換えると、状態はコンポーネントがアンマウントされる(使われなくなる)までのライフサイクル間で共有され、更新する度にコンポーネントを再計算させるような値です。

レンダリングとはReactが何らかのトリガーを感知した時に対象のコンポーネントに対して呼び出されるイベントです。そのイベントでは呼び出した時点でのコンポーネントを計算しどのような画面を表示するかを求め、前回の結果と差分があるかを確認するような処理を行います。この時、差分があればDOMへ反映しユーザーに伝えます。これをコミットと言います。レンダリングという処理自体はDOMへの反映を含んでいないことに注意してください。

淡々と説明するだけではわかりにくいので、理解を助ける例としてカウンターを作ってみます。
カウンターはボタンを押すとボタンに表示される数値が1増えるような単純なコンポーネントで、以下のように実装します。

const Counter: FC = () => {
  let count = 0;
  const increment = () => count++;
  return <button onClick={increment}>{count}</button>;
};

このコンポーネントは、ボタンを押すことで宣言したボタンに表示される数値countが1ずつ増えていくことが期待できそうなコンポーネントです。しかし、ボタンを押しても表示される数値が変わることはありません。
ボタンを押すことでJavaScriptの変数としてのcountは更新されていますが、このコンポーネントでは再レンダリング(初回以降のレンダリング)されることがないため、コミットのきっかけがなく画面への反映は行われません。また、ボタンを押した後に何らかの方法で再レンダリングを発火させてコミットしても異なるレンダリング間でコンポーネント内のローカルな値は保持されないのでcountは0にリセットされます。このような理由で、ボタンに表示される数値は0から変わりません。

ローカルな値は保持されませんが、コンポーネントの外にあるような値は保持されます(コンポーネントのライフサイクルとは関係のない値なので当然と言えば当然ですが)。そのため、コンポーネントの外でグローバルな変数を定義してそれをカウンターに使って、何らかの手段で再レンダリングを起こせばボタンを押した回数分の数値が表示されます。

let count = 0;
const Counter: FC = () => {
  const increment = () => count++;
  return <button onClick={increment}>{count}</button>;
};

ただ、自身の値の変化に合わせて画面が変化しないことや、グローバルな変数を用いているので扱いがとても大変です。具体的な一例として、このコンポーネントを複数箇所で定義することを考えると、定義されたすべての場所で同じ変数を共有するような動作をします。このような外部の値を扱うようなコンポーネントはバグや動作不良の原因になリます。Reactではそのようなバグや不具合を防ぐためにコンポーネントをできるだけ純粋な関数として定義することが求められます。純粋な関数は数学で扱うような関数のような性質を持つもので、同じ入力であれば常に同じ結果を返すような関数のことを指します(例えばy = x^2に同じ数字を何回代入しても結果は同じです)。純粋な関数でなければいけない理由の詳細はこの記事では述べませんが、関数を純粋であると仮定することで大きなパフォーマンス上の利点を拾うことができるなど様々な背景があります。

では、どのような変数を使ってカウンターを実装すれば良いのでしょうか。これまでのことを踏まえると、以下の2つの条件を満たす変数を利用できればカウンターを正常に動かせます。

  • 値の変更に応じて再レンダリングが発火するような変数
  • 異なるレンダリング間で値を保持するような変数

つまり、カウンターを作るためには状態が必要となってきす。Reactでは状態を定義する方法としてuseStateなどのAPIが用意されています。これからuseStateについて詳しくみていきます。

useState

useStateはコンポーネント内で状態を扱うための関数の1つです。

const [state, setState] = useState<State>(initialState);

引数に初期値initialStateをとり、配列(タプル)で状態値stateと状態を更新するための関数setStateを返します。<State>のように状態の型を明示的に指定できます。
状態stateの更新は更新関数setStateによってのみ行われます。引数の値に更新したり

setState(nextState);

元の値に基づいて値を関数のように更新できます。

setState((state) => getNextState(state))

初期値も同じように値だけではなく、関数を受け取ってその返り値に設定できます。

useState(() => createState())

このようなuseStateを使うには2つのルールを守る必要があります。

  • コンポーネントの関数または、後述のcustom hookの内側から呼び出す
  • 関数のトップレベルから呼び出す

例えば以下のようなケース達は許容されません。

ファイルのトップレベルから呼び出す
const [state, setState] = useState<State>(initialState);

const SampleComponent: FC = () => {
  return <></>;
};
通常の関数から呼び出す
const sampleLogic: SampleLogic = () => {
  const [state, setState] = useState<State>(initialState);

  return {
    state,
    setState,
  };
};
分岐内から呼び出す
const SampleComponent: FC = () => {
  if (loading) {
    const [state, setState] = useState<State>(initialState);
  }

  return <></>;
};
分岐後に呼び出す
const SampleComponent: FC = () => {
  if (loading) {
    return <>Loading</>  
  }
  const [state, setState] = useState<State>(initialState);

  return <></>;
};
ループ中で呼び出す
const SampleComponent: FC = () => {
  for (let i = 0; i < 5; i++) {
    const [state, setState] = useState<State>(initialState);
  }

  return <></>;
};

正しく利用するには以下のようにします。

const SampleComponent: FC = () => {
  const [state, setState] = useState<State>(initialState);
  if (loading) {
    return <>Loading</>  
  }

  return <></>;
};

ReactではuseState以外にもこのようなルールを持つAPIがいくつか準備されており、それらのことをhook(フック)と言います。現在するhooks apiはすべてuseから始まる関数名で定義されています。hookは自作できて、それをcustom hook(カスタムフック)と呼びます。カスタムフックは名前がuseから始まる関数で、Reactの提供する他のhookと同じルールが課されます。なぜこのような厳重なルールが課されているのかについては後ほど言及します。

これまでの説明をもとにカウンターを作成すると以下のようになります。

Counter.tsx
const Counter: FC = () => {
  const [count, setCount] = useState(0);
  const increment = () => setCount((count) => count + 1);

  return <button onClick={increment}>{count}</button>;
};

useStateから出荷されたcountsetCountを用いたことで、期待していたボタンを押すとボタンに表示される数値を増やす一連の動きを達成できました。Counterを複数呼び出してもそれぞれ独立したカウンターとして機能することにも注目してください。

初期値の設定

状態の初期値をuseStateで設定する方法は値をそのまま渡す方法と関数を渡す2つの方法がありました。
複雑なロジックのために関数を利用して初期値を生成する必要があるときは、関数のまま渡すようにしてください。

レンダリングのたびに呼ばれる例
[state, setState] = useState(complexLogic());

このように計算後の結果を渡すと、初回のレンダリングだけではなく、再レンダリングでコンポーネントを計算するたびにcomplexLogicを計算してしまいます。

初回のレンダリングだけ呼ばれる例
[state, setState] = useState(complexLogic);
// または
[state, setState] = useState(() => complexLogic());

計算後の結果を渡すのではなく関数自体を渡すことで関数の実行は初回にしか行われないのでパフォーマンスが有利です。

開発環境では初期化が2度行われる

Reactでは関数の純粋性を保つために、特定のhooksに引数として渡した関数は開発環境に限り2回実行されます。この仕様はuseEffectの第一引数に渡した関数が開発環境では2度呼ばれるような挙動が特に有名です。
そして、useStateも例外ではありません。useStateでは開発環境で初期化が2回行われます。
以下のように値をそのまま引数にした場合は何度実行しても結果は変わりません。

const Counter: FC = () => {
  const [count, setCount] = useState(0);
  const increment = () => setCount((count) => count + 1);

  return <button onClick={increment}>{count}</button>;
};

そのため、このコンポーネントは初期化を2回実行されることによる影響はありません。
次に、以下のようなコンポーネントを考えてみます。

let globalCount = 0;
const initialCount = (): number => {
  return globalCount++;
};

const Counter: FC = () => {
  const [count, setCount] = useState(() => initialCount());
  const increment = () => setCount((count) => count + 1);

  return <button onClick={increment}>{count}</button>;
};

このケースではuseStateの初期化によってcountの初期値は2になってしまいます。これは、initialCountが純粋な関数ではなくグローバルな値を参照する関数であることが原因です。そして、1度しか実行されない本番環境であってもこのコンポーネントを複数箇所で扱うと呼び出すたびに初期値を参照する元の値globalCountが大きくなり、初期値はコンポーネントの呼び出された順番によって異なる値を取ります。2回実行される仕様では、このような問題を開発している段階で気づきやすくしてくれます。
この仕様が原因で開発環境において想定外の振る舞いを起こしたときは、1度渡した関数が純粋な関数であるかを良く確認してください(2回呼び出す現象を解決するのではなく、n回呼び出しても問題ない関数に変更してください)。
先ほどのコンポーネントは以下のように、任意の引数を渡すと常に同じ返り値となる関数initialCountを渡すことで、何度実行されても、どこで何回利用されても同じ結果になる純粋なコンポーネントとなります。

const initialCount = (count: number): number => {
  return count++;
};

const Counter: FC = () => {
  const [count, setCount] = useState(() => initialCount(0));
  const increment = () => setCount((count) => count + 1);

  return <button onClick={increment}>{count}</button>;
};

このように一見Reactのバグのように思える機能ですが、我々のコンポーネントが不純になっていることに気づかせてくれるありがたい仕様です。

setStateが持つ2種類の更新方法

useStateの返り値には状態を更新するsetStateがあり、この関数は状態を更新する2種類の方法を持っているのでした。両者にはどのような違いがあり、どちらを使えば良いのかを見ていきます。
まず、元の値に+1するようなハンドラーは以下のようにかけます。

const handler = () => {
  setState(state + 1);
};

const handler = () => {
  setState((state) => state + 1);
};

両者に動作の違いはありません。このケースではsetState(state + 1)がシンプルでわかりやすいこと以外に違いがなく、多くの場合このようにかけるのでsetState((state) => state + 1)のような記法は不要そうに見えます。
次に、同じハンドラーで3回setStateを呼び出すことを考えてみます。

const handler = () => {
  setState(state + 1);
  setState(state + 1);
  setState(state + 1);
};

const handler = () => {
  setState((state) => state + 1);
  setState((state) => state + 1);
  setState((state) => state + 1);
};

この場合、後者は多くの人が想像する通り状態の値が+3されますが、前者では+1しかされません。
これはコンポーネント内で持つ状態をスナップショットで保有していることが原因です。状態の値はレンダリングごとに固定されており、値が変更されることはありません。
そのため、状態値が0の時にhandlerが発火されたと仮定すると、以下のような関数として解釈されます。

const handler = () => {
  setState(0 + 1);
  setState(0 + 1);
  setState(0 + 1);
};

これが前者では+1しかされなかった理由です。たとえ以下のように非同期で状態を読み取ったとしても同様の理由からhandlerを実行した時の値がコンソール上に表示されます。

const handler = () => {
  setState(state + 1);// setState(0 + 1);
  setState(state + 1);// setState(0 + 1);
  setState(state + 1);// setState(0 + 1);
  setTimeout(() => {
    console.log(state);// console.log(0);
  }, 1000);
};

一方後者では実行したタイミングの状態を参照するのではなく、状態の更新をする方法を指定しています。
実はsetStateによる値の更新はすぐに行われるのではなく、それが呼び出されたハンドラ関数の実行完了後にバッチ処理のように行われます(React18以降では、呼び出した関数単位ではなく同時刻に呼び出された関数をすべて同タイミングでバッチ化するようです。これによってレンダリングの機会が減るので従来よりパフォーマンスが向上しました。連続で同じハンドラが呼び出されたときは別のバッチとなるので短い期間で二重送信される場合でも安心して使えます。)。さらに、その処理ではスナップショットの値を参照するのではなく状態の値を直接参照します。後者は指定した値ではなく状態に対する操作を指定しているので、これをもとに考えると確かに最初の想像通りの結果3になるわけですね。
これらの知識をもとにこれまでの動作を振り返ると以下のようにstateは更新されます。

スナップショットの数値が0の時
const handler = () => {
  setState(n => n + 1);// まだ何にも更新されていないので0 + 1
  setState(n + 5);// nは前回のレンダリングの状態値のスナップショットなので0 + 5
  setState(n => n + 1);// バッチ処理中の最新の値5をもとに5 + 1
  setState(n => n + 1);// バッチ処理中の最新の値6をもとに6 + 1
  setState(20);// 定数での指定なので20
  setState(n + 5);// nは前回のレンダリングの状態値のスナップショットなので0 + 5
};

これが2種類の更新方法の違いです。
setStateを使うときは記法の統一という観点から常にsetState((state) => state + 1)のようにしても良いですし、冗長に感じる場合で複数回実行しないときはsetState(state + 1)のように使い分けても良いです。
私はより直感的な動作をしてバグが起きにくいという理由から記法を統一するのをお勧めします。

再レンダリングが起きる条件

再レンダリングは状態が変化することによって引き起こされると言いました。しかし、useStateから吐き出されるstateに変更があれば常に再レンダリングが起きるわけではありません。再レンダリングが起きない条件は2つあります。

  • 等価な値に更新される
  • stateを直接変更する

1つ目の条件である「等価な値に更新される」はReactのパフォーマンス対策の一環として行われます。Object.isを現在の値と更新後の値に対して行いtrueだった場合は再レンダリングを行いません。これによって無駄なレンダリングコストを支払うことを防いでいます。
この条件は実質的に状態が変化していないので再レンダリングが行われないのは当然と言えば当然かもしれませんが、setStateによって状態の更新を試みるだけで再レンダリングが起きるわけではないとういうことに注意が必要です。

2つ目の条件である「stateを直接変更する」は配列やオブジェクトの場合に起こります。他の文字列や数値の場合は直接更新できないので起こり得ないです。例えば以下のケースです。

const [state] = useState<number[]>([]);
const handlePush = () => {
  state.push(1);
};

配列やオブジェクトを定数として定義した場合は自身を書き換えることはできませんが、それを構成する要素の変更ができます。この方法で更新したものはスナップショットの値が書き換えられるだけで状態の値そのものが変化したわけではないので再レンダリングは起きません。再レンダリングを起こしたいときは以下のようにスプレッド構文などを利用して新しい配列やオブジェクトを作成し、setStateを利用して更新するようにしてください。

const [state, setState] = useState<number[]>([]);
const handlePush = () => {
  setState([...state, 1]);
};

つまり、setStateは状態を渡された値に更新するだけではなく、差分があったときに再レンダリングをトリガーする関数ということです。

このようなミスを防ぐために書き込みが不可能な配列やオブジェクトであることを明示的に型で表現すると、間違った使い方をしないのでおすすめです。

const [state, setState] = useState<readonly number[]>([]);
const handlePush = () => {
  setState([...state, 1]);
};

hooksに課された強い誓約

hooksを利用するにあたって、2つの条件がありました。

  • コンポーネントの関数とcustom hookの内側からの呼び出す
  • 関数のトップレベルからの呼び出す

これらの条件はコンポーネントのライフサイクルでのhooksの扱われ方による誓約となります。
コンポーネントは初回レンダリングのタイミングで呼び出されるhooksの順番を保存して、次回以降のレンダリングはその順番をもとにhooksの処理をします。
つまり、コンポーネント外でhooksを呼び出すことはコンポーネントやcustom hooksの中でのみ行える処理(呼び出す順番を保存する処理)が存在していることが理由でできません。
さらに、if文による分岐などレンダリングごとに呼び出されるhooksの順番や個数が異なると思わぬ動作不良につながることから、コンポーネント内のトップレベルにhooksをおく必要があります。if文の終了後であっても分岐内で早期リターンされた場合などを考えると呼び出しの個数の担保ができないので定義できません。

useStateの簡易的な実装としてReactは以下のようなコードを提供しています(コメントなど説明しやすいように編集しています)。

let componentHooks = [];
let currentHookIndex = 0;

// useStateなどのhooksの理解を助けるための簡易的な実装
function useState(initialState) {
  // componentHooksは実行された順番の通りに状態を保存する配列
  let pair = componentHooks[currentHookIndex];
  if (pair) {
    // 2回目以降のレンダリングではこれが呼び出される
    // 次のhooksを呼び出せるようにindexを更新して、pairを返す
    currentHookIndex++;
    return pair;
  }

  // これ以降初回読み込みなので、useStateが返すペアを作る
  // initialStateは初期値(簡単な実装例なので渡せるのは値だけ)
  pair = [initialState, setState];

  // 値をnextStateに変更してレンダリングを行う(簡単な実装例なので渡せるのは値だけ)
  function setState(nextState) {
    pair[0] = nextState;
    rendering();
  }

  // componentHooksの呼び出したindexにpairを登録する
  componentHooks[currentHookIndex] = pair;
  // 次のhooksを呼び出せるようにindexを更新して、pairを返す
  currentHookIndex++;
  return pair;
}

このコードをもとに以下のようなコンポーネントのライフサイクルを考えてみます。

const DoubleCounter: FC = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  return (
    <>
      <button onClick={() => setCount1(count1 + 1)}>{count1}</button>
      <button onClick={() => setCount2(count2 + 1)}>{count2}</button>
    </>
  );
};

初回のレンダリングでは、まだcomponentHooksにhooksが登録されていないため、初期値initialStatesetStateの配列をpairとしてcomponentHooksに登録します。具体的にはcount1を提供するuseStatecomponentHooksの0番目に[0, setState]のように、count2を提供するuseStatecomponentHooksの1番目に[0, setState]のように保存されます。
setCountXは呼び出したcountXの値を更新してrenderingを呼び出し再レンダリングを行わるような関数ですので、それ以降のレンダリングはsetCount1または、setCount2が呼び出されたときに起きます。今回考える簡易的なrenderingcurrentHookIndexを0にセットして、コンポーネントを再度計算するものとします。コンポーネントの再計算はuseStateが初回レンダリングと同じ順番通りに呼ばれ、それぞれcurrentHookIndexを更新してその時点の状態の値を返します。これによってuseStateを区別することなく、呼び出した順番によって保存していた状態を適切に渡すことができます。
これがhooks特にuseStateの裏側で行われる一連の流れです(本当はもっと複雑なあれこれを行います)。

if文の分岐内でhooksが呼ばれる場合を考えます。初回では分岐内のhooksも含めたすべてのhooksが呼び出されcomponentHooksに登録されていたとしても、それ以降のレンダリングで分岐内のhooksが呼ばれないタイミングで、順番が飛ばされるので次にcomponentHooksを参照する時に異なる状態を参照してしまうような事故が生じます。
実際にうまくいかないことを以下のようなケースと先ほどのuseStateの実装をみながら初回レンダリングといくつかの再レンダリングを考えてみてください。

const DoubleCounter: FC<DoubleCounterOrios> = ({ double }) => {
  const [count1, setCount1] = useState(0);
  if (double) {
    const [count2, setCount2] = useState(0);
  }

  return (
    <>
      <button onClick={() => setCount1(count1 + 1)}>{count1}</button>
      {double && <button onClick={() => setCount2(count2 + 1)}>{count2}</button>}
    </>
  );
};

上記のuseState簡易的な実装のため、setStateを呼び出した時に問答無用で再レンダリングするなど実際の仕様とはそぐわない点もありますが、このような実装になっているため、hooksの呼び出しには2つの厳しい条件が課されています。
背景を知ることで清々しい状態でhooksと付き合っていくことができるのではないでしょうか。

おわりに

Reactの基本であるuseStateについて紹介しました。使い慣れている方がほとんどだと思いますが、意外と奥の深い機能だったのではないでしょうか。
useState1つ学ぶだけでもReactのメンタルモデルや思想のようなところが見え隠れしてとても面白いよくできた機能だなと思います。

参考

52
29
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
52
29