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に関連する一般的なバグのほぼすべてを回避できます。