こんにちは。チームのコードレビューをしていて、一番「うっ……」と絶句する瞬間がいつだか分かるかい? そう、「親から渡ってきた `props` の値が変わったから、子コンポーネント側で `useState` の初期値を再計算したい」というコードを見た時だ。
中級へのステップを駆け上がっているエンジニアほど、この罠に綺麗にハマる。コンポーネントのライフサイクルや「同期」という言葉の魔力に囚われて、ついやってしまうんだよね。
今日は、この「propsからstateへの同期」という、実務で最もやりがちで、最もレビューで指摘されるアンチパターンについて、ブラウザの裏側の動きから実践的な解決策まで、徹底的に解剖していこう。
—
なぜ「propsをstateの初期値にコピーする」としぬのか?
まずは、やってはいけないアンチパターンから見ていこう。よくあるのがこんなコードだ。
// ❌ やってはいけないアンチパターン
import { useState } from ‘react’;
function UserProfile({ initialName }: { initialName: string }) {
// propsをそのままuseStateの初期値にしている
const [name, setName] = useState(initialName);
return (
現在の名前: {name}
);
}
「一見、動くじゃん」と思ったそこのあなた。甘い。
このコンポーネントの親が再描画され、`initialName` が別の値に変わったとする。さて、ブラウザの画面はどうなるだろうか?
答えは、「子側の `name` は古い値のまま変わらない」だ。
なぜなら、`useState` の初期化処理(引数)は、コンポーネントの初回マウント時にしか実行されないからだ。2回目以降の親の再描画時、Reactは `useState(initialName)` の引数を完全無視する。すでにメモリ上に確立されているstateの箱をそのまま維持するんだ。
これを無理やり同期させようとして、`useEffect` を持ち出すジュニア(あるいはかつての僕ら)が現れる。
// ❌ さらに悪化させたアンチパターン(同期バグの温床)
import { useState, useEffect } from ‘react’;
function UserProfile({ initialName }: { initialName: string }) {
const [name, setName] = useState(initialName);
// propsの変更を検知してstateを強制的に書き換える
useEffect(() => {
setName(initialName);
}, [initialName]);
// …以下略
}
これをやると何が起きるか?
1. 親から新しい `initialName` が渡ってくる。
2. 子が一度、古い `initialName` で描画される。
3. `useEffect` が発火し、`setName` が走る。
4. 子コンポーネントが強制的に再描画(二重レンダリング)される。
パフォーマンス上の無駄撃ちになるだけでなく、ユーザーが入力中のフォームの値が親の都合で突然上書きされるという、UX的に最悪なバグ(状態の競合)が生まれる。これが「Derived State(派生状態)のアンチパターン」の正体だ。
—
現場で使える「2つの正解アプローチ」
じゃあ、親から受け取った値が変わったときに追従させたい、あるいはリセットしたい場合はどうすればいいのか。実務で使えるアプローチは主に2つある。
アプローチ1:派生状態(Derived State)は作らず、直接計算する(完全な制御)
もし、そのデータが「propsから一意に決まるもの」であれば、そもそも `useState` に入れる必要すらない。レンダー関数内で計算するか、そのまま使えばいいのだ。
// ⭕️ 正解1: stateを持たず、propsをそのまま描画するか派生値として計算する
function UserProfile({ initialName }: { initialName: string }) {
// もし加工が必要なら、stateではなく単なる変数(派生値)にする
const uppercaseName = initialName.toUpperCase();
return (
名前: {uppercaseName}
);
}
「いや、ユーザーが自由に入力・編集できるようにしたいんだ(入力フォームなんだ)」という場合は、次のアプローチの出番だ。
アプローチ2:`key` 属性でコンポーネントごとリマウントする(神の一手)
これが実務で一番美しく、バグを生みにくいベストプラクティスだ。
Reactの `key` プロパティが変わると、Reactは「あ、これ前のとは別物だね」と判断し、古いコンポーネントを完全にアンマウントして、新しいコンポーネントとしてイチからツリーを作り直す(リマウント)。
つまり、`useState` の初期化処理が自然に再度走り、新しい `props` が初期値としてセットされる。
// ⭕️ 親コンポーネント側での制御
function ParentComponent() {
const [userId, setUserId] = useState(1);
const [userName, setUserName] = useState(‘Alice’);
return (
{/ keyにuserIdを渡すことで、ユーザーが変わった瞬間に子を完全にリセットする /}
);
}
// ⭕️ 子コンポーネント側はシンプルに保つ
function UserProfile({ initialName }: { initialName: string }) {
// keyが変わるたびにコンポーネントが破棄・再生成されるため、
// useStateの初期値は常に「最新のinitialName」になる。useEffectは不要!
const [name, setName] = useState(initialName);
return (
setName(e.target.value)}
placeholder=”名前を入力”
/>
);
}
どうだい? このアプローチなら、`useEffect` で無理やりstateを書き換える汚いコードを書く必要が一切なくなる。Reactの仮想DOMのライフサイクルに仕事をさせているから、コードの意図が極めて明確だ。
—
チーフアーキテクトからの実践的なアドバイス
実務の現場でコードレビューをする際、僕はこの問題に直面したらこう問いかけるようにしている。
> 「そのデータ、本当に子側でローカルに持たせる必要ある? それとも親が管理すべき(Controlled Component)?」
もし、その状態が親コンポーネント(あるいはもっと上の階層)と完全に同期しているべき性質のものなら、子側に `useState` を持たせるのをやめて、親から `value` と `onChange` を受け取る「制御されたコンポーネント(Controlled Component)」にするべきだ。
逆に、子側で独自のライフサイクルや一時的なバッファとして持ちたいけれど、特定のタイミング(IDの変更など)でリセットしたいなら、先ほど紹介した `key` の変更テクニックを使う。
Reactの設計思想の根底にあるのは「データの流れの単一方向化(Single Source of Truth)」だ。propsをstateにコピーして同期させようとした瞬間、その真実は二つに分裂し、バグの温床となる。
この原則を頭の片隅に置いておくだけで、君の書くReactコードの品質は一段も二段も跳ね上がるはずだ。さあ、今すぐプロジェクトの `useEffect` を漁って、無駄な同期処理を綺麗にリファクタリングしに行こうか!

コメント