useStateとuseEffect:正しい使い方

1. 概要

useStateとuseEffectはReactの最も基本的な2つのhookですが、最も多くのバグが発生する場所でもあります。無限レンダリング、期待通りに更新されないstate、不要な繰り返し実行されるeffectなどです。この記事では、2つのhookの実際の動作メカニズム、正しい使用パターン、避けるべき一般的なエラーについて説明します。この記事を読み終えることで、「この場所にuseEffectが必要か」という決断をより自信を持ってできるようになります。多くのReact開発者がこの質問に間違った答えを出しているのが現状です。

2. useStateの基本

useStateは[value, setter]のペアを返します。setterを呼び出すたびに、Reactは新しい値を使ってコンポーネントの再レンダリングをスケジュールします。

import { useState } from "react";

function CustomerCounter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>顧客数:{count}</p>
      <button onClick={() => setCount(count + 1)}>顧客を追加</button>
    </div>
  );
}
  • useState(0)はcount = 0を最初のレンダリング時にのみ初期化します。
  • setCount(count + 1)はcountが1増加した状態で新しいレンダリングを作成します。
  • 現在のレンダリングのclosure内のcountは不変です。2つのレンダリング間で自動的に変わることはありません。

注意: setCountを呼び出しても、同じレンダリング内ではcountの値はすぐに変わりません。Reactは更新をバッチ処理し、その後レンダリングします。

3. useState:古いstateに基づく更新

同じイベントハンドラ内でcount(「閉じ込められた」変数)に基づいてsetterを複数回呼び出すと、結果が間違ったものになります。

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // 結果:countは3ではなく1だけ増加します
}

関数型更新を使って、最新の値を正しく取得します:

function handleTripleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  // 結果:countは正しく3増加します
}
  • setterに関数を渡すとき、Reactはprevが常に処理時点での最新のstate値であることを保証します。古いclosureでキャプチャされた値ではなく。
  • ルール:新しいstateが古いstateに依存する場合は、常に関数型更新を使用してください。

4. useState:遅延初期化

初期値の計算が負荷が高い場合は、直接関数を呼び出さないでください。useStateに関数を渡して、1回だけ実行させます。

function parseInitialOrders(raw) {
  console.log("order データを解析中...");
  return JSON.parse(raw);
}

function OrderList({ rawOrders }) {
  // 誤り:parseInitialOrdersはすべてのレンダリングで再実行される
  // const [orders, setOrders] = useState(parseInitialOrders(rawOrders));

  // 正解:最初のレンダリング時のみ実行
  const [orders, setOrders] = useState(() => parseInitialOrders(rawOrders));

  return <p>注文数:{orders.length}</p>;
}
  • 誤ったやり方はparseInitialOrdersをすべてのレンダリングで呼び出します。Reactは最初のレンダリングの結果だけを使いますが。
  • 正しいやり方は関数参照を渡します。Reactはマウント時にだけそれを実行します。

5. useEffectの基本

useEffectはコンポーネントのレンダリングが完了した後に(DOMが更新された後に)副作用を実行します。

import { useEffect, useState } from "react";

function PageTitleUpdater({ orderCount }) {
  useEffect(() => {
    document.title = `あなたは${orderCount}件の注文があります`;
  });

  return <p>現在の注文:{orderCount}</p>;
}
  • 依存配列(第2引数)がない場合、このeffectはマウントと更新を含むすべてのレンダリング後に実行されます。
  • document.titleは典型的な副作用です。React外部の世界(DOM API)と相互作用するため、レンダリングのために値を計算するのではありません。

6. useEffect:依存配列

依存配列は、effectが実行されるタイミングを決定します。覚えておくべき3つのケースがあります。

6.1. 依存配列がない場合

useEffect(() => {
  console.log("すべてのレンダリング後に実行");
});

6.2. 空の依存配列[]

useEffect(() => {
  console.log("マウント後に1回だけ実行");
}, []);

6.3. 値のある依存配列

function CustomerProfile({ customerId }) {
  const [customer, setCustomer] = useState(null);

  useEffect(() => {
    fetchCustomer(customerId).then(setCustomer);
  }, [customerId]);

  return <p>{customer?.name ?? "読み込み中..."}</p>;
}
  • EffectはcustomerIdが2つのレンダリング間で変更された場合のみ再実行されます(Object.isで比較)。
  • 配列からcustomerIdを削除すると、effectは古いcustomerId値(stale closure)を使用します。propが変わるとき、間違った顧客をフェッチします。

注意: ESLintプラグインeslint-plugin-react-hooksのexhaustive-depsルールは、dependencyを見落とした場合に警告します。この結果を理解していない限り、このルールをオフにしないでください。

7. useEffect:クリーンアップ関数

effectがsubscription、timer、またはイベントリスナーを作成する場合、leakを防ぐためにクリーンアップ関数を返す必要があります。

function OrderStatusListener({ orderId }) {
  const [status, setStatus] = useState("pending");

  useEffect(() => {
    const subscription = subscribeToOrderStatus(orderId, setStatus);

    return () => {
      subscription.unsubscribe();
    };
  }, [orderId]);

  return <p>ステータス:{status}</p>;
}
  • クリーンアップ関数は、effectが再実行される前(orderIdが変わるとき)とコンポーネントがアンマウントされるときに実行されます。
  • クリーンアップがない場合、orderIdが変わるたびに新しいsubscriptionが作成されます。最終的に、古いsubscriptionから複数回setStatusが呼び出され、間違った更新またはメモリリークが発生します。
orderId が 1 -> 2 に変わるときの実行順序:
1. orderId = 1 のeffectのクリーンアップ
2. orderId = 2 の新しいeffectが実行

8. 一般的なエラー

8.1. 正しいdependencyの欠落による無限ループ

function BadExample() {
  const [data, setData] = useState({ count: 0 });

  useEffect(() => {
    // 毎回のレンダリングで新しいobject -> effectは常に「変更」を検出(dataをdepsに入れた場合)
    setData({ count: data.count + 1 }); // 再レンダリング -> effectが再実行 -> 無限
  }, [data]);

  return <p>{data.count}</p>;
}

関数型更新と停止条件の分離で修正するか、このロジックが実際の副作用を必要としない場合はuseEffectを完全に削除します。

8.2. setTimeout/setIntervalでのstale closure

function Countdown() {
  const [seconds, setSeconds] = useState(10);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((prev) => prev - 1); // 関数型更新を使用。直接secondsを使わない
    }, 1000);

    return () => clearInterval(id);
  }, []); // depsが空。effectの中で直接secondsを読まないため

  return <p>{seconds}</p>;
}
  • 関数型更新の代わりにsetSeconds(seconds - 1)と書くと、closureは最初のeffect実行時からseconds = 10を「覚えて」います。カウントダウンは間違った値で止まります。

8.3. useEffectを使って派生値を計算

// 誤り:冗長。余分なレンダリングサイクルが発生
function OrderSummaryBad({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price, 0));
  }, [items]);

  return <p>合計:{total}</p>;
}

// 正解:レンダリング中に直接計算
function OrderSummaryGood({ items }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>合計:{total}</p>;
}

「誤った」バージョンは余分なレンダリングサイクルを消費します。古いtotalでレンダリング、effectが実行、setTotalが正しい値を持つ2番目のレンダリングをトリガーします。「正しい」バージョンはレンダリング中に直接計算されるため、effectも従属stateも必要ありません。

9. useEffectを使うべきでない場合

注意: useEffectはReact外部のシステムとの同期(DOM、API、subscription、timer)専用です。既存のprops/stateから値を直接計算できる場合は、effectを使用しないでください。

3つの兆候として、以下のことが考えられます:

  • 別のstateから派生値を計算するためにeffectを使用している(通常の変数またはuseMemoを使用)。
  • ユーザーのイベントを処理するためにeffectを使用している(onClick、onSubmitハンドラで直接処理)。
  • propが変わるときにstateをリセットするためにeffectを使用している。通常、effectの代わりにkeypropを使用してReactにコンポーネントを自動的にremountさせることができます。
// effectを使ってcustomerIdが変わるときにformをリセットする代わりに:
// useEffect(() => { setForm(initialForm); }, [customerId]);

// keyを使用してコンポーネントをremountし、Reactが内部stateをリセット:
<CustomerForm key={customerId} customerId={customerId} />

10. hookの動作をテストする

Jest + React Testing Libraryを使用して、実装の詳細ではなく実際の動作をテストします。

npm install --save-dev @testing-library/react @testing-library/user-event jest
// CustomerCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomerCounter } from "./CustomerCounter";

test("「顧客を追加」ボタンをクリックしてcountが増加", async () => {
  const user = userEvent.setup();
  render(<CustomerCounter />);

  expect(screen.getByText("顧客数:0")).toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: "顧客を追加" }));

  expect(screen.getByText("顧客数:1")).toBeInTheDocument();
});
  • テストはrenderを呼び出してから、ユーザーの動作のようなuserEventを使用した実際のクリックを模擬します。
  • アサーションは、内部stateではなく表示されるコンテンツ(getByText)に基づいています。これがReactで正しいテストを行う方法です。実装ではなく動作をテストします。
PASS  ./CustomerCounter.test.jsx
  ✓ 「顧客を追加」ボタンをクリックしてcountが増加 (45 ms)

11. 結論

useStateは外見上シンプルですが、closureの値が各レンダリングごとに「閉じ込められている」ことを忘れると、エラーが起こりやすくなります。新しいstateが古いstateに依存する場合は、関数型更新を使用してください。useEffectは強力ですが、React外部システムとの同期にのみ使用してください。値がレンダリング中に直接計算できる場合は、effectでラップしないでください。依存配列とクリーンアップ関数をしっかり理解することで、これら2つのhookに関連する一般的なバグのほぼすべてを回避できます。

reacthooksjavascriptfrontendtypescript