🔒

親から渡ってきた props を「あえて」useState の初期値に設定してみる

2023/12/11に公開

よくやりがちな間違い

React を書きはじめのころによくやりがちな間違いとして 「親から渡ってきた props を useState の初期値として設定してしまい、(props の)変更がロックされてしまう」 というものがあるかと思います。

例として、以下のようなユースケースを考えてみましょう。

  • 2人の人物のリストがあり、どちらかを選択すると詳細フォームにその人のメールアドレスが自動入力される
    • 詳細フォームは編集可能
  • 詳細フォームには入力されたデータを更新(実際にはログに出力しているだけ)するための適用ボタンも用意されている

今回これらを実現するために、下記のような実装をしたとします。

App.tsx
import { useState } from "react";
import { Person, persons } from "./constant";
import DetailView from "./DetailView";

export default function App() {
  const [selected, setSelected] = useState<Person>(persons[0]);

  return (
    <div>
      {persons.map((person) => (
        <button
          type="button"
          key={person.id}
          onClick={() => setSelected(person)}
        >
          {person.id === selected.id ? person.name.toUpperCase() : person.name}
        </button>
      ))}
      <DetailView initialEmail={selected.email} />
    </div>
  );
}
DetailView.tsx
import { ChangeEvent, useState } from "react";

export default function DetailView({ initialEmail }: { initialEmail: string }) {
  const [email, setEmail] = useState(initialEmail);

  return (
    <div>
      <input
        type="text"
        value={email}
        onChange={(event: ChangeEvent<HTMLInputElement>) =>
          setEmail(event.target.value)
        }
      />
      <button type="button" onClick={() => console.log(email)}>
        Apply
      </button>
    </div>
  );
}
constant.ts
export const persons = [
  {
    id: 1,
    name: "Dominik",
    email: "dominik@dorfmeister.cc",
  },
  {
    id: 2,
    name: "John",
    email: "john@doe.com",
  },
] as const;

// persons 配列の各要素の型をユニオン型として定義
export type Person = typeof persons[number];

実際に動かしてみればわかりますが、この実装だと仕様通りに動作しません。
メールアドレスを直接書き換えて「適用」させることは可能ですが、「John」をクリックしても詳細フォームは値が更新されません。

これは、React が以下のような挙動をするためです。

  • 初回レンダー:
    • 親から渡されるinitialEmailの値を初期値として、emailステートが初期化される
  • 再レンダリング以降:
    • 親から渡されるinitialEmailの値が変化したとしても、emailステートは影響を受けない

公式ドキュメントを読む

このいかにも忘れがちな 「state の初期値が初回レンダリング時のみにしかセットされない」 という性質については、実は公式ドキュメントでもしっかりと言及されています。

https://ja.react.dev/learn/choosing-the-state-structure#don-t-mirror-props-in-state

props を state にコピーしない
...
props を state に「コピー」することが意味を持つのは、特定の props のすべての更新を意図的に無視したい場合だけです。慣習として、新しい値が来ても無視されるということを明確にしたい場合は、props の名前を initial または default で始めるようにします。

「props を state にコピーするな」と書いてあり、否定的な意見を持っているようですね。

ちなみに最後の

props の名前を initial または default で始めるようにします

という部分は、React Hook Form を利用している方なら馴染深いのではないでしょうか?

React Hook Form でフォームの初期値を設定する際には

useForm({
  defaultValues: {
    firstName: props.firstName,
    lastName: props.lastName,
  },
});

こんなふうにdefaultValuesを用います。
このdefaultValuesは先ほども説明した通り、新しい値が来ても無視されてしまうため、defaultと名付けられているのですね。

このあたりについては以下のドキュメントも参考にしてください。

https://react-hook-form.com/docs/useform#defaultValues

解決策

さて、では冒頭のアプリの話に戻りましょう。

ここまでの話をまとめると...

「初回レンダリングの際のキャッシュが残り続けてしまうため props が変化しても state の初期値は更新されない」

のでした。

これを回避するにはどう実装を変更したらよいのでしょうか?

ここでは解決策を 3 つほど紹介します。

① 条件付きレンダー

まず 1 番簡単なのが 「条件付きレンダー」 でしょう。

https://ja.react.dev/learn/conditional-rendering

例えばモーダルを使用する場合などは、こちらの方法をよく用いるはずです。

以下は、先ほどのアプリケーションの詳細フォーム部分をモーダルに置き換えたものです。

App.tsx
import { useState } from "react";
import { Person, persons } from "./constant";
import DetailView from "./DetailView";

export default function App() {
- const [selected, setSelected] = useState<Person>(persons[0]);
+ const [selected, setSelected] = useState<Person | undefined>();
+ const close = () => setSelected(undefined)

  return (
    <div>
      {persons.map((person) => (
        <button
          type="button"
          key={person.id}
          onClick={() => setSelected(person)}
        >
          {person.name}
        </button>
      ))}
-     <DetailView initialEmail={selected.email} />
+     {selected && (
+       <div
+         style={{
+           position: 'fixed',
+           top: '0',
+           left: '0',
+           paddingTop: '100px',
+           width: '100%',
+           height: '100%',
+           backgroundColor: 'rgba(0,0,0,0.4)',
+         }}
+       >
+         <div
+           style={{
+             display: 'flex',
+             justifyContent: 'center',
+             width: '80%',
+             height: '50vh',
+             margin: 'auto',
+             backgroundColor: 'white',
+           }}
+         >
+           <DetailView initialEmail={selected.email} close={close} />
+           <span style={{ cursor: 'pointer' }} onClick={close}>
+             &times;
+           </span>
+         </div>
+       </div>
+     )}
    </div>
  )
}
DetailView.tsx
import { useState } from "react";

- export default function DetailView({ initialEmail }: { initialEmail: string }) {
+ export default function DetailView({ initialEmail, close }: { initialEmail: string, close: () => void }) {
   const [email, setEmail] = useState(initialEmail)

   return (
     <div>
       <input
         type="text"
         value={email}
         onChange={(event) => setEmail(event.target.value)}
       />
       <button
         type="button"
         onClick={() => {
           console.log(email)
+          close()
         }}
       >
         Apply
       </button>
     </div>
   )
}

これだと仕様通りに動きます。

というのも、モーダル部分が条件付きレンダーにより「アンマウント → 再マウント」され、保持していた初回レンダリング時の email ステートを一旦破棄するためです。

React では state はレンダーツリー内の位置に紐づいています。
同じ位置の同じコンポーネントでは state が保持されますし、逆に同じ位置に以前とは違うコンポーネントがレンダーされる or コンポーネント自体がアンマウントされると state はリセットされます。

今回は強制的にアンマウントされるため、props を useState の初期値に入れたままでもマウント時にはリセットされ、仕様通りに動作します。

ただこちらはモーダルのような特殊な UI 上でしか成り立たないため、DetailView をどこでもレンダリング可能にしたい場合は別の解決策が必要です。

② Lifting state up(state のリフトアップ)

お次が有名な 「Lifting state up」 です。

https://ja.react.dev/learn/sharing-state-between-components

state をなるべく親コンポーネントに寄せて、子コンポーネント以下を「制御された(Controlled)もの」として扱う手法です。

今回の例だと email の state を UI ツリーの最上部へ移動させ、DetailView を完全に Controlled なコンポーネントとして扱います。
こうすることで、DetailView はローカル state を保持する必要がなくなり(= props を state に入れる問題はそもそも発生しない)、必要な情報はすべて props 経由で処理していくことになります。

App.tsx
import { useState } from "react";
import { Person, persons } from "./constant";
import DetailView from "./DetailView";

export default function App() {
  const [selected, setSelected] = useState<Person>(persons[0]);
+ const [email, setEmail] = useState<string>(selected.email)

  return (
    <div>
      {persons.map((person) => (
        <button
          type="button"
          key={person.id}
          onClick={() => setSelected(person)}
        >
          {person.id === selected.id ? person.name.toUpperCase() : person.name}
        </button>
      ))}
-     <DetailView initialEmail={selected.email} />
+     <DetailView email={email} setEmail={setEmail} />
    </div>
  );
}
DetailView.tsx
import { ChangeEvent, useState } from "react";

- export default function DetailView({ initialEmail }: { initialEmail: string }) {
+ export default function DetailView({ email, setEmail }: { email: string, setEmail: Dispatch<SetStateAction<string>> }) {

-   const [email, setEmail] = useState(initialEmail);
    return (
      <div>
        <input
          type="text"
          value={email}
          onChange={(event: ChangeEvent<HTMLInputElement>) =>
            setEmail(event.target.value)
          }
        />
        <button type="button" onClick={() => console.log(email)}>
          Apply
        </button>
      </div>
    );
}

ただこのアプローチは汎用性が高い一方で、欠点も存在しています。

今回の例だと、詳細フォームに文字を入力するたびに App コンポーネント全体が再レンダリングされてしまいますし、email ステートのスコープが広すぎるという欠点もあります。

email 自体は 詳細フォーム の関心事なので、できる限り関連性が強い DetailView コンポーネントに寄せたほうが良いでしょう(= コロケーション)

③ key を用いて"完全"非制御(Uncontrolled)なものとして扱う

最後に紹介するのが、今まで紹介してきた ① と ② の方法のちょうど中間くらいにあたる手法です。

全く同じ UI / UX を維持しつつ email ステートのスコープを狭くして、必要なときのみ詳細フォームを再マウントさせています。

① と ② のいいとこ取りのような実装で、かなり理想的と言えるでしょう。
(タイトルにもある 「あえて props を useState の初期値として設定してみる方法」 とはこれにあたります。)

まず最初に実装例をご紹介します。

App.tsx
import { useState } from "react";
import { Person, persons } from "./constant";
import DetailView from "./DetailView";

export default function App() {
  const [selected, setSelected] = useState<Person>(persons[0]);

  return (
    <div>
      {persons.map((person) => (
        <button
          type="button"
          key={person.id}
          onClick={() => setSelected(person)}
        >
          {person.id === selected.id ? person.name.toUpperCase() : person.name}
        </button>
      ))}
-     <DetailView initialEmail={selected.email} />
+     <DetailView key={selected.id} initialEmail={selected.email} />
    </div>
  );
}
DetailView.tsx
import { ChangeEvent, useState } from "react";

export default function DetailView({ initialEmail }: { initialEmail: string }) {
  const [email, setEmail] = useState(initialEmail);

  return (
    <div>
      <input
        type="text"
        value={email}
        onChange={(event: ChangeEvent<HTMLInputElement>) =>
          setEmail(event.target.value)
        }
      />
      <button type="button" onClick={() => console.log(email)}>
        Apply
      </button>
    </div>
  );
}

違いがあるのは、以下の部分のみです。

- <DetailView initialEmail={selected.email} />
+ <DetailView key={selected.id} initialEmail={selected.email} />

ここで大事になってくるのが 「key 属性」 です。

React において key は特別なものです。
一般的にはリストをレンダリングする際に用いられますが、今回のように 「コンポーネントの state をリセットすること」 を目的に使用されることもあります。

https://ja.react.dev/reference/react/useState#resetting-state-with-a-key

https://ja.react.dev/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key

https://ja.react.dev/learn/rendering-lists#why-does-react-need-keys

(先ほども説明しましたが)基本的には state はレンダーツリー内の位置に紐づいていますが、key を用いることで key それ自体を位置に関する情報として React に使用させることができます。

こうすることで、たとえツリー内の同じ位置にレンダーしていたとしても key が異なっていた場合にはそれらを異なるものとして認識するようになり、state を破棄して無理やりリセットさせることが可能になります。

今回だと、どちらかの人物を選択するたびに key がperson.idで異なる値になるため、email ステート がちゃんとリセットされます。

※ 【余談】useEffect を用いたアンチパターン

「props が変化したときに state をリセットする」 というのは、一見すると useEffect を用いたら書けるように思えます。

今回だと以下のような感じでしょうか。
( React Hook Form のresetでもこれと似たようなことをしますね。)

DetailView.tsx
export function DetailView({ initialEmail }) {
    const [email, setEmail] = React.useState(initialEmail)

    React.useEffect(() => {
        setEmail(initialEmail)
    }, [initialEmail])

    return (...)
}

正常に動作するには動作するのですが、このような形で useEffect を用いるのは一般的にはアンチパターンだと考えられています。

以下の有名な 「You Might Not Need an Effect 」 でもこれについてはしっかりと言及されています。

https://ja.react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes

useEffect は、例えば localStorage といった「React 以外の外部システム」とコンポーネントを同期させるために用いるべきであり、今回のように「props と state との同期」目的で基本的には用いるべきではありません。

またこのようなコードにすることで、実際に画面に描画されるまで非効率的なプロセスを経ることになります。

今回だと以下のようなプロセスをたどります。

  1. DetailView とその子コンポーネントがまず初回の state でレンダリングされる
  2. その後、useEffect によって state が新しくなり、その値により再度レンダリングが走る
  3. ブラウザにペイント

1 回分余計にレンダリングが走ることになり、非効率的であることがわかるでしょう。

useEffect は追跡が困難なエラーを引き起こしかねない

今回の条件としてはあくまで 「別の人物(Dominik か John)が選択されるたびに state をリセットしたい」 であって「email が変更されたときにリセットしたい」わけではありません。

今は email であるため一般的には「一意である」と考えられていますが、もしこれが同じデータ(例えば同じ苗字)を持っていた場合だとどうでしょう?

これだと別の人をクリックしたとしても、エフェクト内の依存配列は変化しないため再実行されず、詳細フォームの情報が書き換わることはありません。

このように (エッジケースの場合)追跡が困難なエラーにつながりかねないので、こういった点でも useEffect の使用はできるだけ避けたほうがよいでしょう。

おわりに

いかがでしたでしょうか?

今回は React を使い始めの頃にやりがちな 「親 props の値を useState の初期値に設定した場合に、props の変更がロックされてしまう」 という問題の回避策を 3 つほど紹介しました。

改めて整理しておくと

  • 条件付きレンダー
    • シンプルでわかりやすい
    • ただモーダルのような特殊な UI 上でしか成立せず、いつでも表示させたいというユースケースでは難しい
  • Lifting state up(state のリフトアップ)
    • 推論が容易で、汎用性が高い
    • パフォーマンス的によろしくなく、コロケーションの思想にも反する
  • key を用いて"完全"非制御(Uncontrolled)なものとして扱う(おすすめ!タイトルの方法はこれ)
    • コロケーションの思想に則っており、必要なときのみレンダリングが走る
    • 常に安定した key が存在するわけではない

以上 3 つの方法を用いることで、これらの問題は解決できるのでした。
それぞれにメリット・デメリットが存在しているため、一概にどれがベストであるとは言いづらいですが、個人的には最後の方法が 1 番しっくり来るなという印象です。

ただ 「useEffect を用いた state と props を無理やり同期させる方法」は React の思想的に見てもアンチパターンですし、エラーにつながる可能性もあるため、あまりおすすめしません。

今回の記事がどなたかの参考になれば幸いです。

最後までご覧いただき、ありがとうございました。


COUNTERWORKS ではエンジニアを絶賛募集中です!!
約 10 兆円規模の市場がある「商業不動産」の世界に興味ある方がいらっしゃたら、まずは以下のリンクからカジュアル面談でもご応募ください!

https://counterworks.co.jp/recruit/?utm_source=zenn&utm_medium=referral&utm_campaign=advent-calendar-2023&utm_content=11

参考資料

https://tkdodo.eu/blog/putting-props-to-use-state

COUNTERWORKS テックブログ

Discussion