はじめに
ミツカリのたなしゅん(@tanashun_dev)です。
ReactHookForm、皆さん使っておりますでしょうか。 React開発においてかなりメジャーなFormライブラリですね。 ミツカリでも採用しています。
ReactHookFormにはuseFieldArrayという動的に入力項目を増減させる仕組みがあるのはご存知でしょうか。
以下が公式ガイドです。
これを使って実装をしていたところ、ハマってしまった部分があったのでそちらの解消方法を紹介します。
ハマってしまったこと
以下のコードを見てください。 ※スタイル用のコードは要点と関係ないので除外しています
import React from "react"; import { useFieldArray, useFormContext, Controller } from "react-hook-form"; export const DynamicTextFields: React.FC = () => { const { control } = useFormContext(); const { fields, append, remove } = useFieldArray({ control, name: "texts" }); return ( <div> {fields.map((field, index) => ( <div key={field.id}> <TextFieldRow name={`texts[${index}].text`} /> {index === 0 ? ( <button type="button" onClick={() => append({ text: "" })}> 追加 </button> ) : ( <button type="button" onClick={() => remove(index)}> 削除 </button> )} </div> ))} </div> ); }; type RowProps = { name: string; }; const TextFieldRow: React.FC<RowProps> = ({ name }) => { const { control } = useFormContext(); return ( <div> <Controller control={control} name={name} defaultValue="" render={({ field }) => ( <input {...field} /> )} /> </div> ); };
このコードには不具合があります。わかりますか? さすがにコードだけで挙動の不具合が見抜ける人はほとんどいなさそうです。
CodeSandboxに用意したのでこちらで実際に動かしてみてください。
https://codesandbox.io/p/sandbox/epic-surf-68ndvw
どうでしょうか?わかりましたか?
まだ不具合に気づけていない方は以下の操作をしてみてください。
- テキストボックスを3つの状態にする
- 2番目のテキストボックスを削除する
- 元3番目のテキストボックスへ入力しようとする
3の操作で正常に入力できないのがわかりましたか?
これがなぜ起きているのか、またどうすれば解消されるのかをここから解説していきます。
解決
この不具合を見ていく前にこちらのコードを見てください。
import React from "react"; import { useFieldArray, useFormContext, Controller } from "react-hook-form"; type FormValues = { texts: { text: string; }[]; }; export const DynamicTextFields: React.FC = () => { const { control } = useFormContext<FormValues>(); const { fields, append, remove } = useFieldArray({ control, name: "texts" }); return ( <div> {fields.map((field, index) => ( <div key={field.id}> <TextFieldRow name={`texts[${index}].text`} /> {index === 0 ? ( <button type="button" onClick={() => append({ text: "" })}> 追加 </button> ) : ( <button type="button" onClick={() => remove(index)}> 削除 </button> )} </div> ))} </div> ); }; type RowProps = { name: string; }; const TextFieldRow: React.FC<RowProps> = ({ name }) => { return ( <div> <input name={name} /> </div> ); };
先ほどのコードとほとんど同じですが、このコードだと先程のような不具合は発生しません。
前者のコードと後者のコードで異なる点は、mapで動的に生成している先でForm要素として独立しているかどうかです。
前者のコードはmap内の行要素のTextFieldRowコンポーネントがそれ単体でuseForm内で利用できるようなコンポーネントとして定義されています。 それに対し、後者のコードはTextFieldRowコンポーネントはuseFormを考慮していなくてただのHTML要素だけが定義されています。
まずは両者の違いでなぜ挙動が異なるのかを考えてみます。
それにはまずuseFieldArrayの挙動をよく知る必要があります。
useFieldArrayは内部的にはuseFormとは別のStateを管理し、変更結果をuseForm側のFormValueのStateへ反映させるような挙動をしています。
わかりやすく言うと、useFormのcontrol._fields
とuseFieldArrayのfields
は別物だということです。
そこで前者と後者の違いを考えると、前者はuseFormContextから取得されるfields
をinput要素に渡していますが、後者はuseFieldArrayが提供するfields
をinput要素に渡しています。
ここが大きな違いです。後者だとremoveによるuseFieldArray上の変更がinputにダイレクトに伝わりますが、前者だとuseFormContext側のfields
を参照しているため、useFieldArrayによる操作が伝わらないということです。
この実装自体がNGな実装かというとそうではないと思います。 useFieldArrayで動的に増減したいinputコンポーネントがuseFieldArrayを使わない単独での利用もされたいケースはいくらでもあります。 今回はそのケースにおける不具合解消を考えていくことになります。
では、本ケースにおいて不具合が発生している原因を考えていきましょう。 このとき、着目すべきは真ん中の要素を削除するという操作が何を引き起こしそうかにアタリをつける必要があります。
削除によって引き起こされる挙動で怪しいのはindexです。 3つの要素がある中で2番目(indexでいえば1)の要素を削除すると、元々index0だった要素は変わらずindex0ですが、index2だった要素はひとつズレてindex1に変化します。
本不具合もこの周辺が原因です。
試しに以下のようなコードを書きました。
const removeTextField = useCallback( (targetIndex: number) => { // targetIndex以外の要素を取得しておき、一度すべてをremoveしてからappendし直す // 単にremove(targetIndex)をすると、indexがズレて壊れてしまう const nowValues = getValues("texts"); const newItems = nowValues.filter((_, index) => index !== targetIndex); for (let i = 0; i < nowValues.length; i++) { remove(0); } newItems.forEach((item) => { append(item); }); }, [getValues, append, remove] );
簡単に説明するとindexをズラさずに再度要素を作り直すことで整合性を担保しようというコードです。
このコードは正しく動作していて、不具合を解消することができました。
しかし、根本解決になっていない気がします。
実はこんなことをしなくても不具合だった状態から1行変えるだけで直すことができます。
export const DynamicTextFields: React.FC = () => { const { control } = useFormContext(); const { fields, append, remove } = useFieldArray({ control, name: "texts" }); return ( <div> {fields.map((field, index) => ( <div key={field.id + "-" + index}> // keyにindexを追加するだけ! <TextFieldRow name={`texts[${index}].text`} /> {index === 0 ? ( <button type="button" onClick={() => append({ text: "" })}> 追加 </button> ) : ( <button type="button" onClick={() => remove(index)}> 削除 </button> )} </div> ))} </div> ); };
なぜこれだけで解決できるかというと、それはReactがコンポーネントをmapで生成するときの仕組みにあります。
keyはReactがHTMLにレンダリングしたときの要素の特定に使っているものです。一般的にはindexをkeyに使用するべきでないとされています。それはテーブルなどで配列に挿入されたときにレンダリング効率を高めるためです。しかし、今回の場合はその再レンダリング抑止が挙動を破壊する原因になっているのでindexをkeyに入れる方が正しいです。
修正前はremoveしたとしてもkeyが変わらなかったのでReactがその要素を見つけられなくなっていました。修正後はkeyが変わることでReactはindexが変わった後の要素を正しく見つけられるようになっています。
ちなみに、先に紹介したappendし直す方法でも回避できた理由は、appendし直すことによってfield.idが変更され、結果的にkeyが変更になっていたからです。
Reactの内部的な仕組みをもっと理解していかないとこの手の問題には悩まされてしまいますね。精進します。
Reactにおけるkeyの役割やlistのレンダリングについては以下が参考になります。
アイテムのインデックスを key として使用したくなるかもしれません。実際、key を指定しなかった場合、React はデフォルトでインデックスを使用します。しかし、アイテムが挿入されたり削除されたり、配列を並び替えたりすると、レンダーするアイテムの順序も変わります。インデックスをキーとして利用すると、微妙かつややこしいバグの原因となります。
と書いてあるように、基本的にはindexはkeyに利用すべきではありません。
再レンダリングの必要性をよく考え、適切なkeyを設定できるようにならないといけませんね。
おまけ
そもそもindex
をnameに使用しているから問題が起こるんじゃないかと思った人もいるかもしれません。
例えば以下のようにnameにfield.id
を使ってhash形式にすればkeyがfield.id
のままでも本不具合は解消されます。
export const DynamicTextFields: React.FC = () => { const { control } = useFormContext(); const { fields, append, remove } = useFieldArray({ control, name: "texts" }); return ( <div> {fields.map((field, index) => ( <div key={field.id}> <TextFieldRow name={`texts.${field.id}.text`} /> {index === 0 ? ( <button type="button" onClick={() => append({ text: "" })}> 追加 </button> ) : ( <button type="button" onClick={() => remove(index)}> 削除 </button> )} </div> ))} </div> ); };
しかし、この実装だとdefalutValuesでの初期化時に別の問題が発生します。 初期値を渡すことのないFormなのであればこれでも構いませんが、たいていの場合、初期値を設定することがあると思います。
export default function App() { const formMethods = useForm({ defaultValues: { texts: [ { text: "", }, ], }, }); return ( <div className="App"> <FormProvider {...formMethods}> <h1>DynamicTextFields</h1> <DynamicTextFields /> </FormProvider> </div> ); }
index
を使った実装であれば上記のように初期値を設定することができます。
しかし、index
を使わない実装だと配列ではなくhashで定義しないといけない気がするので以下のような初期化をしてみます。
export default function App() { const formMethods = useForm({ defaultValues: { texts: }, }); return ( <div className="App"> <FormProvider {...formMethods}> <h1>DynamicTextFields</h1> <DynamicTextFields /> </FormProvider> </div> ); }
残念ながらこれはuseFieldArrayに認識してもらえません。 useFieldArrayは内部的にArrayを生成するものなので、初期値もArrayを期待しています。 そのため、Arrayで初期化しないと認識されません。
試しに初期化方法はそのままで、nameだけをindexを使わないものに変更してFormValuesがどのように変化するかを見てみます。
useFieldArrayが自動管理するArrayとnameで指定したhash形式が混ざってしまいました。
keyが同じtextsだから悪いのかもしれませんね。nameに渡すkeyをhashTexts
に変えてみます。
useFieldArrayの管理と分離することができました。
二重にStateを持ってしまうことにはなりますが、再レンダリングは抑制できそうです。
また、Formの値はだいたいサーバにPOSTすると思いますが、サーバ側がhashではなくarrayを想定している場合はonSubmit内でhashをarrayに詰め直す処理が必要になってしまいます。 再レンダリングの抑制の代償としては少し大きいかもしれません。 動的要素がレンダリング負荷が高いものでないのであればkeyにindexを付与する方がコードの可読性は保てそうだなと感じました。
現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!