【実務・中級編】 状態の同期におけるアンチパターン – React実践ガイド

おい、最近コードレビューしていて「あ、またこれやっちゃってるな…」って思うアンチパターンがあるんだよね。それが、「propsからstateを初期化する」、そして「複数の状態を同期させようとして沼にハマる」というやつだ。

中級へのステップアップの時期によくある罠なんだけど、これ、実務の現場だと平気で数時間、下手をすると原因特定に丸一日溶かすような厄介なバグを生む温床になる。

今回は、なぜこれが「やってはいけないアンチパターン」なのか、Reactが裏側でどう動いているのかも含めて、徹底的に叩き込んでいく。しっかりついてきてくれよ。

—

1. なぜ「propsからstateの初期化」は悪魔の所業なのか?

まずは、よくあるこのコードを見てほしい。

// 【やってはいけないアンチパターン】
function UserProfile({ initialName }: { initialName: string }) {
// propsで受け取った値でstateを初期化している
const [name, setName] = useState(initialName);

return (

setName(e.target.value)} />

);
}

一見、何の問題もなさそうに見えるよね?「親から初期値をもらって、それを自分のローカルstateで持つのの何がダメなの?」って思うかもしれない。

だが、現実のプロダクト開発を思い出してほしい。親コンポーネントのデータが非同期通信(API)の完了によって更新され、`initialName` の値が変わったとき、この `UserProfile` コンポーネントはどうなると思う?

答え:「子コンポーネント内の `name` は一ミリも変わらない」。

なぜなら、`useState` の初期化処理(遅延初期化を除く)は、コンポーネントの「初回マウント時」にしか走らないからだ。親から新しいpropsが渡されても、一度生成されたstateはそのまま居座り続ける。これによって、親と子のデータが完全に乖離する「同期ズレのバグ」が完成する。

ブラウザとReactの裏側の動き

Reactのファイバーツリーのライフサイクルを思い出そう。
コンポーネントがマウントされるとき、Reactは内部のメモリスロットに初期値を保存する。2回目以降のレンダリング(再描画)では、`useState` は引数(今回の場合は `initialName`)を完全に無視してメモリスロットから既存の値をフェッチする。

だから、親が何度propsを変えようとも、子は自分の古いstateを握りしめたままになるんだ。

—

2. 複数の状態を同期させるもう一つの地雷

「いや、じゃあ `useEffect` で監視して同期させればいいじゃん!」と思ったそこの君。それもまた、実務で最も恐れられるアンチパターンのひとつ、「同期のための副作用(Syncing State with Props via useEffect)」だ。

// 【これもダメなアンチパターン】
function UserProfile({ initialName }: { initialName: string }) {
const [name, setName] = useState(initialName);

// propsが変わったらstateを強制的に上書きする(危険!)
useEffect(() => {
setName(initialName);
}, [initialName]);

return setName(e.target.value)} />;
}

これをやると何が起きるか?
1. 親から新しい `initialName` が来る。
2. 子が再レンダリングされる。
3. `useEffect` が発火し、`setName` が走る。
4. stateが更新されたことで、子コンポーネントが「もう一度」強制再レンダリングされる。

これ、無駄なレンダリングが走るだけじゃない。親と子の間で状態の「ボールトス」が始まり、バグの温床になる。特にフォーム入力中などに親の再描画が走ると、ユーザーが入力中の文字が強制的に巻き戻されるという、最高にイライラするUIテロが完成する。

—

3. 正解:状態は「単一の真実のソース(Single Source of Truth)」に絞れ

じゃあ、どうすればいいのか?
結論はシンプルだ。「その値のマスターデータは誰が握っているべきか」を考えること。

親がマスターを握っているなら、子は自分のローカルstateとして持たず、「制御されたコンポーネント(Controlled Component)」として親に依存させればいい。

パターンA:親に状態を完全に委譲する(コントロールされている状態)

type UserProfileProps = {
name: string;
onNameChange: (newName: string) => void;
};

// 正しいアプローチ:子は自分のstateを持たず、受け取ったpropsをそのまま使う
export function UserProfile({ name, onNameChange }: UserProfileProps) {
return (

{/ 入力値の変更は即座に親のコールバックへ流す /}
onNameChange(e.target.value)}
placeholder=”名前を入力”
/>

);
}

  • メリット: 状態の矛盾が絶対に起きない。親が絶対的な正義(Single Source of Truth)だからだ。

—

パターンB:完全に独立させる(初期値としてのみ使う場合)

「いや、受け取った初期値をもとに、あとはこのコンポーネント内で完全に独立してゴリゴリ状態をいじりたいんだ!」というケースもあるよね。例えば、編集モーダルの初期値などだ。

その場合は、propsを「初期値」として扱うことが明確に伝わるように命名し、必要であれば `key` 属性を使ってコンポーネントごとリマウントさせるのが定石だ。

// 1. コンポーネント側:あくまで「初期値」であることを名前で明示する
function UserEditForm({ defaultName }: { defaultName: string }) {
// 命名を initial ではなく default や、それが「初期値であること」がわかるようにする
const [name, setName] = useState(defaultName);

return (
setName(e.target.value)} />
);
}

// 2. 親側での使い方
function ParentContainer() {
const [userId, setUserId] = useState(1);
const user = fetchUser(userId); // 仮のデータ取得

return (
// ★重要: keyにuserIdや一意のIDを渡すことで、
// ユーザーが切り替わった瞬間にコンポーネントごと破棄・再生成させ、stateを綺麗に初期化する

);
}

この `key` を利用したリマウントテクニックは実務でめちゃくちゃ使う。Reactは `key` が変わると「あ、全く別のコンポーネントに変わったんだな」と認識して、古いDOMツリーと内部stateをごっそりパージして新しく作り直してくれる。これぞReactの正しい作法だ。

—

シニアからのまとめ

フロントエンドのコードが汚れていく原因の多くは、「どこがデータの管理者(Owner)なのか」という境界線が曖昧になることだ。

1. propsを直接 `useState` の初期値にするな(どうしてもやるなら `key` でライフサイクルを制御しろ)。
2. `useEffect` で無理やりpropsとstateを同期させるな(無駄なレンダリングとバグの元凶)。
3. データは常に上から下に流し(Props)、変更はイベント(Callback)で上に戻せ。

この基本原則を守るだけで、君の書くReactコードは劇的に美しく、そしてバグ知らずになるはずだ。さあ、明日からのコードレビューで後輩たちの怪しいコードを優しく(そして的確に)指摘してやってくれよな!

コメント

タイトルとURLをコピーしました