useState(computeInitialValue()) と書いたコードは、実は再レンダリングのたびに computeInitialValue() を呼んでいます。初期値として使われるのは最初の1回だけなのに、計算だけは毎回走っているということです。軽い処理なら問題ありませんが、localStorage の読み込みや大きな配列の生成が入ると、無視できないコストになります。これを避けるのが「遅延初期化(lazy initialization)」で、値ではなく関数を useState に渡すだけで実現できます。この記事では、その仕組みと書き方、間違えやすい落とし穴までを解説します。
目次
初期値は毎回評価されている
useState の初期値は「最初のレンダリングのときだけ使われる」というのは正しいのですが、それはReact が受け取った値をどう使うかの話です。useState(createInitialTodos()) と書いた場合、createInitialTodos() は useState を呼ぶ前に JavaScript として評価されるので、レンダリングのたびに実行されます。React は2回目以降その結果を捨てているだけです。
import { useState } from 'react';
function createInitialTodos() {
console.log('createInitialTodos が実行された');
const todos = [];
for (let i = 0; i < 50000; i++) {
todos.push({ id: i, text: `todo ${i}`, done: false });
}
return todos;
}
function TodoList() {
// ここは「関数を呼んだ結果」を渡している → 毎レンダリングで実行される
const [todos, setTodos] = useState(createInitialTodos());
const [text, setText] = useState('');
// 入力するたびに再レンダリングが起き、そのたびにログが出る
return (
<input value={text} onChange={(e) => setText(e.target.value)} />
);
}
この例で入力欄に文字を打つと、text の更新で再レンダリングが起き、そのたびに 50000 件の配列生成が走ります。結果は使われないのに時間だけかかるので、入力がもたつく原因になります。
関数を渡せば初回だけ実行される
解決方法はとても簡単で、呼び出しの括弧を取って関数そのものを渡すだけです。useState は引数が関数だった場合、それを初期化関数(initializer function)とみなし、最初のレンダリングでのみ呼び出して戻り値を初期値にします。2回目以降は呼び出されません。
function TodoList() {
// 括弧を付けずに関数を渡す → 初回レンダリングでだけ実行される
const [todos, setTodos] = useState(createInitialTodos);
// 引数が必要なときはアロー関数で包む
const [items, setItems] = useState(() => createItems(20));
const [text, setText] = useState('');
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
引数を渡したい場合は useState(() => createItems(20)) のようにアロー関数で包みます。「呼び出しを遅らせるためのラッパー」を書いている、と考えると自然です。2つの書き方の違いを整理すると次のようになります。
| 書き方 | 関数が実行されるタイミング |
|---|---|
useState(createInitialTodos()) | 毎回のレンダリング(結果は初回だけ使われる) |
useState(createInitialTodos) | 初回のレンダリングのみ |
useState(() => createItems(20)) | 初回のレンダリングのみ(引数付き) |
よくある使いどころ:localStorage から復元する
実務でもっとも遭遇するのが、localStorage や sessionStorage から初期値を読み込むケースです。ストレージへのアクセスと JSON.parse() は同期処理なので、毎レンダリングで走らせたくありません。遅延初期化にすれば読み込みは初回だけになります。
import { useState } from 'react';
const DEFAULT_SETTINGS = { theme: 'light', fontSize: 16 };
function useStoredSettings() {
const [settings, setSettings] = useState(() => {
// 初回レンダリングでだけ実行される
const saved = localStorage.getItem('settings');
if (!saved) return DEFAULT_SETTINGS;
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
} catch {
// 壊れた JSON が入っていても落ちないようにする
return DEFAULT_SETTINGS;
}
});
return [settings, setSettings];
}
ほかにも、日付の計算、URL のクエリ文字列のパース、一意な ID の生成(ただし ID 用途なら useId の方が適切な場面が多い)、重い初期状態の組み立てなどが遅延初期化の出番です。useReducer にも同じ考え方の第3引数 init が用意されていて、useReducer(reducer, initialArg, init) と書くと init(initialArg) が初回だけ実行されます。
関数そのものを state にしたいとき
この仕様の副作用として、関数を状態として保持したいときにそのまま渡せないという問題があります。useState(myFunction) と書くと、React は初期化関数だと解釈して myFunction() を呼んでしまうからです。関数自体を初期値にしたいときは、二重に包んで useState(() => myFunction) と書きます。
const sortByName = (a, b) => a.name.localeCompare(b.name);
function List() {
// NG: sortByName が「初期化関数」として実行されてしまう
// const [comparator, setComparator] = useState(sortByName);
// OK: 関数を返す関数にする
const [comparator, setComparator] = useState(() => sortByName);
// 更新するときも同様。更新関数形式と区別が付かないため関数で包む
const changeSort = (fn) => setComparator(() => fn);
return <button onClick={() => changeSort(sortByName)}>名前順</button>;
}
更新するときも同じ注意が必要です。setComparator(fn) と書くと、React は「前の状態を受け取って新しい状態を返す更新関数」だと解釈して fn(前の状態) を実行してしまいます。setComparator(() => fn) と包んでください。
思ったとおりに動かないとき
初期化関数が2回実行される
開発中に console.log が2回出る場合、StrictMode が原因である可能性が高いです。React は開発モードで初期化関数を意図的に2回呼び、純粋(同じ入力なら同じ結果を返し、副作用を持たない)かどうかを検証します。本番ビルドでは1回だけです。逆に言えば、初期化関数の中で API を呼んだり、カウンターを増やしたりといった副作用を書いてはいけない、というサインでもあります。
props が変わっても初期値が更新されない
useState(() => props.value) と書いても、props.value が変わった後に状態が追随することはありません。初期化関数はあくまで初回だけの処理だからです。これは遅延初期化に限らず useState 全般の性質です。props に応じて状態をリセットしたいときは、コンポーネントに key を付けて作り直すのが React 公式でも推奨されている方法です。
サーバーサイドレンダリングでエラーになる
Next.js などで useState(() => localStorage.getItem('key')) と書くと、サーバー側には localStorage が存在しないためエラーになります。また、サーバーとクライアントで初期値が食い違うとハイドレーションの不一致が発生します。ブラウザ専用の値を読むときは、初期値は既定値にしておき、useEffect の中で読み直して状態を更新する形が安全です。
そもそも遅延初期化が不要なケース
useState(0)、useState('')、useState([]) のようなリテラルの初期値に、遅延初期化は必要ありません。関数で包んでも速くならず、かえって読みにくくなるだけです。適用するのは「呼び出しにコストがかかる関数」を渡しているときだけで十分です。
まとめ
useState(重い関数()) と書くと、その関数は再レンダリングのたびに実行され、結果は初回以外捨てられます。呼び出しの括弧を外して useState(重い関数) とするか、引数が必要なら useState(() => 重い関数(引数)) と包むことで、初回のレンダリングでのみ実行される遅延初期化になります。localStorage からの復元、大きな配列の生成、クエリ文字列のパースなど、コストのかかる初期値の準備で効果があります。useReducer の第3引数 init も同じ仕組みです。注意点として、関数そのものを状態にしたいときは useState(() => fn) と二重に包む必要があること、開発中は StrictMode によって初期化関数が2回呼ばれるため副作用を書いてはいけないこと、そして props が変わっても初期値は再計算されないことを押さえておきましょう。単純なリテラルの初期値に遅延初期化は不要です。