小手先に見えるテクニックでも、実はReact的に考えられる
皆さんこんにちは。React、使っていますか? Reactを″正しく”使うことは難しいと感じる方も多いのではないでしょうか。
特に筆者はReactの正しい使い方に厳しく、こんな記事も出しています。熟練のReact使いでもなければ、この記事を読んで難しいと思うのも仕方がありません。
useEffectに関しては、React公式のドキュメントでも「そのエフェクトは不要かも」というページがあり、useEffectを使ってしまいがちだが、useEffectの使用が適していない場面について解説されています。
この記事の目的
上記の公式ドキュメントで解説されている中でもkeyを使うテクニック(あとでこの記事でも説明します)に関しては、今より未熟だった昔の筆者は、あまり良く思っていませんでした。なぜなら、それが 「Reactらしくない、小手先のテクニック」 に見えたからです。
しかし、よくよく考えれば、これもReactにおける理想的な世界(宣言的UIが徹底され、手続き的な考え方が排除された世界)に適合する形で理解できることに気づきました。そのため、今はこれが小手先のテクニックではなく、Reactの考え方に合った、有効なテクニックであると考えています。
そこで、この記事ではこのテクニックに対する筆者の考え方を共有します。これにより、皆さんがより自信を持ってこのようなテクニックを活用し、不適切なuseEffectを避けられるようになれば幸いです。
useEffectを避けるためにkeyを使うテクニック
この記事では、公式ドキュメントで紹介されている以下のテクニックに焦点を当てます。
これは、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
ある程度は理解できるのですが、自分としてはコンポーネントの内部の都合が使う側に漏れているように感じてしまいます。
それと、配列の場合は key を使うことを lint が担保してくれますが、この場合使う側で必要かどうかを判断が必要なのが問題かと思います。せっかく typescript で props の型を定義しているのに、そこに含まれない key (省略可能)を使わざるえないのが。。。
コメントありがとうございます。その気持ち悪さは理解できます。今回の場合、最終的に
UserProfileFormの仕様が「後からuserpropを変更しても何も起こらない」という仕様になったから、使う側でkeyの考慮が必要になったと理解することができると思います。useEffectの良くない使い方を直す過程でコンポーネントの仕様変更が起こったということです。コンポーネントの仕様が分かりにくかったり、直観的でなかったりすると、「コンポーネントの内部の都合が使う側に漏れている」と感じられることになります。今回はこのケースであり、ここはコンポーネントのインターフェースの改善の余地がありそうだと思います。
例えば、
inputのdefaultValueに倣って、userではなくdefaultUserというprop名にするとかはありかもしれません。この辺りは、次回以降の記事でまた取り上げたいと思っています。