状態の同期という名のパンドラの箱:propsからstateを初期化するアンチパターンの解剖学
こんにちは。フロントエンドのコードベースを日々監査していると、いまだに絶望的な気分になるコードに出会うことがある。その最たるものが、「propsからstateを初期化する」という、一見無害に見えてその実、アプリケーションを静かに蝕むアンチパターンだ。
Reactのメンタルモデルを少しでもかじった人間なら「同期は悪だ」と本能的に察知するはずだが、実務の泥臭い現場では、期限に追われた開発者が「とりあえず親から渡された初期値をローカルでいじりたいから」という理由で、この禁忌を犯してしまう。
今回は、このアンチパターンがなぜ生まれ、ブラウザのランタイムやReactのレンダリングパイプラインにおいてどのような悲劇を引き起こすのか、そしてどうやってこの呪縛から逃れるべきなのかを、骨の髄まで解説しよう。
—
1. なぜ「propsからstateの初期化」に手を出してしまうのか
まずは、よくある「やってはいけない」コードを見てみよう。
import { useState } from ‘react’;
type UserProfileProps = {
initialEmail: string;
};
// ❌ やってはいけないアンチパターン:propsをuseStateの初期値に直結させる
export const UserProfileCard = ({ initialEmail }: UserProfileProps) => {
const [email, setEmail] = useState(initialEmail);
return (
/>
現在の保持email: {email}
);
};
一見、何の問題もないように見える。`initialEmail`をコンポーネントの初期状態として取り込み、ユーザーがinputをいじればローカルの`email`ステートが更新される。
しかし、ここに時限爆弾が仕込まれていることに気づくだろうか?
親コンポーネント側で非同期通信(APIリクエストなど)が走り、後から`initialEmail`の値が変更されたとする。その時、この`
Reactの仕様上、`useState`の初期化(引数)は、初回のマウント時(Mount Phase)にしか評価されない。 親から渡される`initialEmail`が後から変わろうとも、一度生成されたローカルステートの初期値は微動だにしない。親のデータと子のローカルステートは完全に乖離し、画面には古いメールアドレスが表示され続けるという、ユーザーからの問い合わせ確定コースのバグが完成する。
—
2. 状態の同期(Syncing State)という名の泥沼
この問題に直面したジュニア・シニア問わず多くのエンジニアが、さらに悪い手を打つ。それが`useEffect`を使った「無理やりな状態の同期」だ。
import { useState, useEffect } from ‘react’;
type UserProfileProps = {
initialEmail: string;
};
// ❌ さらに悪化したアンチパターン:useEffectでpropsの変更をstateに強制同期させる
export const UserProfileCardBad = ({ initialEmail }: UserProfileProps) => {
const [email, setEmail] = useState(initialEmail);
useEffect(() => {
// 親のpropsが変わるたびにローカルステートを強制上書きする
setEmail(initialEmail);
}, [initialEmail]);
return (
setEmail(e.target.value)}
/>
);
};
これの何が問題なのか? ブラウザのレンダリングとReactの内部スケジューリングの観点から分解してみよう。
1. 二重レンダリング(Wasted Render)の発生
親が新しいpropsを渡し、子がマウントされた後、`useEffect`が発火する。`setEmail`が呼ばれることで、子は再レンダリングを強制される。つまり、1回のデータ変更に対して、DOMのコミットが2回発生する。パフォーマンスの観点から見ても最悪だ。
2. 「情報源の二重化(Single Source of Truthの崩壊)」
データソースが「親のprops」なのか「子のstate」なのか曖昧になる。ユーザーがフォームに入力している最中に、親側で何らかの理由(ポーリングや別コンポーネントからの楽観的更新など)で親のpropsが更新された場合、ユーザーが入力中だった未保存のデータが突然親のデータで上書きされ、入力中のテキストが吹き飛ぶという最悪のUXを引き起こす。
—
3. 正解:制御されたコンポーネント(Controlled Component)と派生状態
では、どう設計すべきなのか。答えはシンプルだ。「ローカルステートを持たないこと」。
親コンポーネントがすべての状態の責任を持ち(Single Source of Truth)、子は単なる「見た目の投影(Pure FunctionとしてのUI)」に徹する。いわゆる「制御されたコンポーネント」のパターンだ。
type UserProfileProps = {
email: string;
onEmailChange: (newEmail: string) => void;
};
// ⭕️ 完璧な設計:状態を親にリフトアップし、子は完全にpropsに依存する
export const UserProfileCardGood = ({ email, onEmailChange }: UserProfileProps) => {
return (
/>
);
};
これならば、状態の不整合や`useEffect`による無駄なレンダリングサイクルとは完全に無縁になる。データフローは常に「親から子へ(Top-Down)」の一方向のみであり、Reactのリアクティブなパラダイムと美しく調和する。
—
4. どうしても「初期値」として扱いたい場合の唯一の例外(Derived Stateのアンチパターン回避)
「いや、どうしても親から渡された値は初期値としてのみ使い、あとは完全に独立したローカル編集フォームとして切り離したいんだ」という要件もあるだろう。例えば、詳細モーダルを開くたびに、初期値をセットして編集させたい場合などだ。
この場合、`useEffect`で同期を取るのではなく、`key`属性を用いたコンポーネントのライフサイクル制御(Remounting)を使うのが、Reactアーキテクチャ的に最も洗練されたアプローチだ。
import { useState } from ‘react’;
type EditFormProps = {
userId: string;
initialEmail: string;
};
// フォームの内部状態を管理するコンポーネント
const EditForm = ({ initialEmail }: { initialEmail: string }) => {
// マウント時の1回のみ初期化される(これで十分)
const [email, setEmail] = useState(initialEmail);
return (
setEmail(e.target.value)}
/>
);
};
// 親コンポーネント
export const UserModal = ({ userId, initialEmail }: EditFormProps) => {
return (
💡 keyにuserId(またはモーダルが開いたときのタイムスタンプなど)を指定する。
これにより、対象ユーザーが変わった瞬間に古いコンポーネントは破棄され、
新しいStateを持ったコンポーネントがゼロからマウントされる。
useEffectでゴリゴリ状態を同期させる必要が消え去る。
/}
);
};
この手法の美しさは、Reactのコアエンジンが持つ「コンポーネントのツリー構造の破棄と再構築(Reconciliation)」のアルゴリズムをそのまま利用している点にある。`useEffect`という副作用の温床に頼らず、宣言的にライフサイクルをリセットできるため、コードの予測可能性が劇的に向上する。
—
まとめ:Reactアーキテクチャの美学
Reactを使っていると、しばしば「状態をどう同期させるか」という泥沼にハマりがちだ。しかし、同期が必要になった時点で、大抵の場合、設計のどこかが間違っている。
- 状態は一つの情報源(Single Source of Truth)に集約する。
- propsを直接stateの初期値に代入しない。どうしても必要な場合は`key`によるリマウントを検討する。
- `useEffect`をデータの同期(Stateの更新)のために使わない。
この鉄則を死守するだけで、あなたの書くReactアプリケーションは、予測不能なバグや無駄なレンダリングの呪縛から解放され、驚くほど堅牢でメンテナブルなものに生まれ変わるはずだ。
さあ、今すぐコードベースを開き、`useEffect`の中で親のpropsをstateにブチ込んでいる箇所を検索し、美しいアーキテクチャへとリファクタリングしよう。ギークとしての腕の見せ所だ。

コメント