小手先に見えるテクニックでも、実はReact的に考えられる
皆さんこんにちは。React、使っていますか? Reactを″正しく”使うことは難しいと感じる方も多いのではないでしょうか。
特に筆者はReactの正しい使い方に厳しく、こんな記事も出しています。熟練のReact使いでもなければ、この記事を読んで難しいと思うのも仕方がありません。
useEffectに関しては、React公式のドキュメントでも「そのエフェクトは不要かも」というページがあり、useEffectを使ってしまいがちだが、useEffectの使用が適していない場面について解説されています。
この記事の目的
上記の公式ドキュメントで解説されている中でもkey
を使うテクニック(あとでこの記事でも説明します)に関しては、今より未熟だった昔の筆者は、あまり良く思っていませんでした。なぜなら、それが 「Reactらしくない、小手先のテクニック」 に見えたからです。
しかし、よくよく考えれば、これもReactにおける理想的な世界(宣言的UIが徹底され、手続き的な考え方が排除された世界)に適合する形で理解できることに気づきました。そのため、今はこれが小手先のテクニックではなく、Reactの考え方に合った、有効なテクニックであると考えています。
そこで、この記事ではこのテクニックに対する筆者の考え方を共有します。これにより、皆さんがより自信を持ってこのようなテクニックを活用し、不適切なuseEffectを避けられるようになれば幸いです。
key
を使うテクニック
useEffectを避けるためにこの記事では、公式ドキュメントで紹介されている以下のテクニックに焦点を当てます。
これは、key
を使ってコンポーネント内のステートをリセットするテクニックです。
ここでは、よくある例として「ユーザーのプロフィールページ」と、そこから呼び出される「プロフィール編集フォーム」を考えます。プロフィール編集フォームは、親コンポーネントからuser
オブジェクトをpropsとして受け取り、その情報を元にフォームの初期値を設定します。
const UserProfileForm: React.FC<{ user: User }> = ({ user }) => {
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</form>
);
};
上記の実装では、親が再レンダリングされて違うuser
が渡されても、ステート(name
とemail
)は初期値に戻りません。Reactではコンポーネントが再レンダリングされてもステートは保持されますから、これは通常の挙動です。
しかし、このようなコンポーネントでは、親から渡されるuser
が変更されたときに、フォームの内容を新しいuser
の情報でリセットするという要件がありがちです。この要件を満たすために、useEffect
を使って以下のように実装することが考えられます。
// ✖ useEffectのよくない使い方
useEffect(() => {
setName(user.name);
setEmail(user.email);
}, [user]);
これはuseEffectの望ましい使い方ではありません。なぜなら、useEffectを「user
が変わったことを検知する」ために使っており、useEffectの本来の使い方である「コンポーネントが存在することの影響を表現する」ために使っていないからです。
この問題を解決するために公式ドキュメントで紹介されているテクニックは、key
を使う方法です。UserProfileForm
を使う側で、このようにkey
を設定します。
<UserProfileForm key={user.id} user={user} />
これにより、user
が変わるたびにUserProfileForm
コンポーネントが再作成され、その結果、内部のステートも初期化されます。これで要件を満たしたコンポーネントが実現できます。
keyを使うテクニックの解釈
昔の筆者は、このkey
を使うテクニックを微妙だなと感じていました。「特定のタイミングでコンポーネントのステートをリセットする」というのは手続き的な考え方で、宣言的UIの考え方に反しているように思えたからです。しかも、key
を使うとステートがリセットされるのはReactの挙動をハックしているように見えて、key
が指定されていても「ステートをリセットする」という意図がコードからは明確に読み取れないのが問題だと考えていました。
しかし最近、改めてReactの公式ドキュメントを読んだところ、key
のテクニックも宣言的UIの枠組みで考えられることがしっかりと書かれていました。実は、key
の挙動を「ステートのリセット」としてではなく「コンポーネントが別のインスタンスであることを示す」もの、JSXに対するアノテーションとして捉えることで、Reactの考え方に合った解釈が可能になるのです。
user
が変わったときの再レンダリングの挙動を見ると、次のようになるでしょう。
<UserProfileForm key="alice" user={user} />
↓レンダリング↓
<UserProfileForm key="bob" user={user} />
このとき、「1つのUserProfileForm
インスタンスのkey
が変わった」という見方をするのではありません。 「key
がalice
のUserProfileForm
インスタンスが消えて、key
がbob
の新しいUserProfileForm
インスタンスが現れた」 と考えます。
Reactのデフォルトの挙動では、レンダリング前後で <UserProfileForm />
がレンダリングされていたら、それは同じインスタンスのpropsが変わったと推論されます。key
を明示することで、そうではなく別のインスタンスであることをReactに伝えているのです。
// keyが無いときのReactの解釈: 同じインスタンスのpropsが変わった
<UserProfileForm user={aliceUser} /> → <UserProfileForm user={bobUser} />
// keyがあるときのReactの解釈: インスタンスの消滅と出現が同時に起こった
<UserProfileForm key="alice" user={aliceUser} /> → 無 (aliceインスタンスが消えた)
無 → <UserProfileForm key="bob" user={bobUser} /> (bobインスタンスが現れた)
この考え方であれば、ステートが初期化される(ように見える)挙動は以下のように理解できます。
-
user
が変わったときにUserProfileForm
の新しいインスタンスが作られ、画面表示はそちらに置き換えられる。 - 新しいインスタンスは初期状態から始まるため、ステートが初期化されたように見える。
このkey
の使い方は、ユーザーが変わったら別のUserProfileForm
を表示する、言い換えれば「ユーザーごとにその人用のUserProfileForm
をレンダリングする」ということをReactに理解してもらうための表現なのです。
このようなkey
の使い方は、Reactの基本である、「純粋関数によって状態からUIを計算し、リコンシリエーションにより効率的に画面を更新する」というやり方に合致しています。純粋関数の返り値はJSXの構文で表現されたコンポーネントツリーであり、key
はそのツリーの解釈を補正するためにコンポーネントツリーに付加される情報です。
そう考えると、key
が宣言的UIの本質にまで食い込んでいるわけではなく、技術的な都合で使うものだということも分かります。JSX(で表現されるコンポーネントツリー)では、同じコンポーネントが別のpropsでレンダリングされているとき、それを「同じインスタンスのpropsが変わった」のか「別のインスタンスに置き換わった」のかをデフォルトでは区別できず、そこはランタイム(React)の推論に委ねられます。key
はその推論を補正するための追加情報であり、「置き換わった」という情報を表現するためにJSXという技術とReactのアーキテクチャの都合上、必要になるものなのです。
仮想的なコードで理解する
これまで解説したように、このコードでは、key
が変わったらUserProfileForm
のインスタンスが変わることをReactに伝えています。
<UserProfileForm key={user.id} user={user} />
これがピンと来ない人は、以下のような仮想的なコードで考えると理解できるかもしれません。つまり、意図としては異なるユーザーに対しては異なるUserProfileForm
を用意している、ということです。
{user.id === "alice" && <UserProfileForm user={user} />}
{user.id === "bob" && <UserProfileForm user={user} />}
…(以下あらゆるユーザーIDに対応)…
最初の1行のコードは、意図としてはこういう風に、各ユーザーに対してその人専用のUserProfileForm
を出し分けることをしたいのです[1]。しかし実際には、あらゆるユーザーIDに対してUserProfileForm
をベタ書きするのは不可能です。そのため、ソースコード上はUserProfileForm
を1つだけ書き、key
でインスタンスの違いを伝えることにしているのです。
key
が変数になっている場合、意図としては「あらゆる異なる値に対して別のインスタンスを用意している」ということをReactに伝えたいのだと考えれば良いのです。
配列のkeyとの関係
key
はReactでリストをレンダリングするときにも使います。例えば、以下のようなコードです。
{users.map((user) => (
<UserProfileForm key={user.id} user={user} />
))}
多くのReactユーザーがkey
と聞いて思い浮かべるのはこの使い方でしょう。key
を指定しないとReactが警告を出すため、必須のものとして認識されています。
このような配列のkey
と、先ほど説明した「propsが変わったときにステートをリセットするためのkey
」は、別々のものではありません。むしろ、同じ目的で、同じ概念として使われているのです。
key
は、JSXで表現されたコンポーネントツリーにおいて、複数のレンダリングの間で一貫したインスタンスの識別を可能にするためのものです。key
があると、Reactランタイムによる推論に頼らずに、より正確にインスタンスを同定できます。
配列においてkey
の指定が必須として扱われているのは、配列の要素が入れ替わったり削除されたりすることが起こった場合、Reactがインスタンスを正確に同定するのが難しいからです。そのため、このケースではユーザーによる付加情報が必須化されているのです。
配列以外のケースでも、key
によってインスタンスの同定を補正できることは同じです。ステートをリセットしたい場合も、key
を使うことで「別のインスタンスである」ことをReactに伝えられます。
まとめ
Reactにおいてkey
を指定するのは、コンポーネントのインスタンスを一貫して同定するための情報を提供するためです。これにより、Reactがコンポーネントのライフサイクルをプログラマーの意図通りに管理できるようになります。「propsが変わったときにステートをリセットする」ためにkey
を使うテクニックは、「前のインスタンスが消えて新しいインスタンスが現れた」という情報をReactに伝えるためのものです。
この仕組みは技術的な都合ではあるものの、Reactのコアとなる宣言的UIの枠組みを補強するものです。そのため、ハック的な何かではなく、宣言的UIの考え方に則ったテクニックとして受け入れられます。
key
を用いてプログラムの意図を正確に表現することで、Reactのランタイムが意図を正確に理解できるようになり、結果として要件通りの動作が得られます。このように「意図をプログラムを落とし込む」ことは、Reactに限らずソフトウェア開発全般において重要な考え方です。そう思うと、key
を使うテクニックも、Reactを使いこなす上で重要なスキルの一つと言えるでしょう。
-
余談ですが、実際にこう書いて
user.id
がaliceからbobに変わった場合、UserProfileForm
のステートはリセットされます。JSX上で違う位置にあるため、2つのUserProfileForm
は別のインスタンスとして扱われるからです。 ↩︎
Discussion