はじめに
本記事はエーピーコミュニケーションズ Advent Calender 2022のラスト、25日目の記事です。
ラストということでRustの記事でも書こうと思ったのですが、ただでさえ寒いこの時期にさらに気温を下げることはないかと思いやめました。
(私は北海道に住んでいるということもあり、寒さに強い分ギャグも寒くなりがちかもしれません)
さて、あらためましてコンテナソリューション事業部の髙井です。
つい先日Next.jsをさわって遊んでいた際、シンプルな入力フォームを作ろうとしたら簡単そうで意外と面倒だったので、今日はそこで遭遇したポイントをまとめていきます。
つくるもの
項目の追加とチェックボックスによる削除に対応したリストを作り、各項目の編集ボタンをモーダルで表示させるイメージです。
UIはChakraUI、データフェッチはSWR、フォーム管理はReact Hook Form(RHF)を使ってやってみましょう。
実際に始めようとすると、このあたりのライブラリ選定から時間かかりますよね。
ライブラリのインストール
ChakraUIは公式のインストール手順に従ってyarn add
のほか、アプリのルートをChakraUIのProviderでラップします。
SWRとRHFはそれぞれ以下のコマンドのみでOKです。
yarn add react-hook-form
yarn add swr
データフェッチ部分をHookに切り出す
多くのフォームでは非同期APIでデータを取得してきて、そのデータをフォームの初期値として設定します。
ということでSWRを使ってデータフェッチ処理をカスタムフックに切り出しします。
import useSWR from 'swr'; import { useRouter } from 'next/router'; export type SampleData = { id: number; name: string; car: string; phrase: string; }; export const useSampleData = () => { const router = useRouter(); const sampleId = router.query.sampleId as string; const fetchSampleData = async () => [ { id: 1, name: 'たくみ', car: 'ハチロク', phrase: '走ってみなきゃわかんねぇ', }, { id: 2, name: 'せいじ', car: 'ランエボ', phrase: 'アウトオブ眼中', }, ]; return useSWR(['sample', sampleId], fetchSampleData); };
設定項目のIDがURLパスに含まれていて、そのIDをuseRouter()
経由で取得してデータフェッチをしているイメージですね。
Next.jsではファイルベースのルーティングができ、[sampleId].tsx
のようにページを切れば簡単に動的なパス情報を取得できるので便利です。
実際にはそのIDを使って取得するデータを切り替えるわけですが、今回はサンプルのため固定値を返します。
useSWR()
の第1引数を適切に指定することでSWRがクライアントごとにキャッシュを効かせてくれます、あまりにも便利。
フォームの初期値にフェッチ結果を設定する
RHFではフォームの初期値に対して、以下のようなAPIで初期値を設定できますが、これは同期的に初期データが取得できる場合にのみ通用します。
const defaultValue = someValue; const { register } = useForm({ defaultValue });
多くのユースケースではデータフェッチが終わってからの反映になりますので、RHFのreset()
を使いましょう。
export const SampleForm = () => { const { data, isLoading } = useSampleData(); const { reset } = useForm<{ data: SampleData[] }>(); useEffect(() => { if (isLoading) return; reset({ data }); }, [isLoading]); // ...
各種操作用のボタンの状態管理をする
項目が存在しないときは削除ボタンを無効にしたり、すべての項目にチェックをいれたら全選択ボタンのチェックも同時に有効するなど、多少のUXに配慮した状態管理を考えていきます。
操作としては、以下があります。
- 項目を末尾に追加する
- チェックされた項目を削除する
- 全項目をチェックする/外す
- 各項目をチェックする/外す
さて、追加削除があるため「現在の項目数」を追跡する必要があります。これにはRHFのwatch()
が使えます。
RHFでは現在のフォームデータを内部で管理してくれていますが、これをユーザー側から監視してリレンダリングするための機能がwatch()
です。
const { reset, watch } = useForm<{ data: SampleData[] }>(); const items = watch('data');
これでitems
の中にSampleData[]
が入ります。引数にオブジェクトのkeyを入れると対象のオブジェクトが返されるという仕様です。
項目を末尾に追加する
chakraUIではButtonのコンポーネントにdisabled
としてbooleanを渡せばボタンの有効/無効を制御できます。
項目数が上限maxCount
に到達したら無効になるのが正しい状態です。
<Button leftIcon={<AddIcon />} onClick={addItem} disabled={items?.length === maxCount} > 項目を追加する </Button>
また、上では項目追加時のハンドラとして未定義のaddItem()
を使用したので定義しておきましょう。
const addItem = async () => { const names = ['けいすけ', 'りょうすけ', 'きょういち', 'しんご', 'たけし']; const name = names[(names.length * Math.random()) | 0]; setValue('data', items?.concat({ id: items?.length, name, car: '', phrase: '' })); };
今回はサンプルなので適当にランダム値を追加していますが、本来は項目内容を設定するモーダルなどを表示して、その内容を反映するなどの処理が必要になるでしょう。
項目を削除する
項目が0個のときに無効になるのが正しい状態です。またそれだけでなく、項目に1つもチェックが付いていなければ無効であるのが正しい状態です。
すなわち、!items?.length
となります。
まだ各項目のチェックボックスについては未定義ですが、チェックが付いている項目のインデックスを保持するstateをcheckedIndices
としましょう……と言いたいところですが、indexes
とindices
のどちらを使うべきかの判断で宗教論争が発生し世界平和が乱れるため、checkedItems
にしましょう。
const [checkedItems, setCheckedItems] = useState<number[]>();
こちらの条件は、さきほどと同様に!checkedItems?.length
とします。
<Button leftIcon={<DeleteIcon />} onClick={deleteItem} disabled={!items?.length || !checkedItems?.length} > 項目を削除する </Button>
また、あわせてdeleteItem()
を定義します。
const deleteItem = async () => { setValue( 'data', items?.filter((_: SampleData, i: number) => !checkedItems.includes(i)), ); setCheckedItems([]); };
ここで使っているsetValue()
はRHFの管理している値をユーザー入力ではなくコード側から更新するための関数です。
useForm()
のプロパティとして存在しているため、以下のようにして取り出せます。
const { register, reset, setValue } = useForm<{ data: SampleData[] }>();
全項目チェック
全項目がチェックされているかは、checkedItems
とitems
の長さが等しいかで判断できます。ただし、これだと項目が1つも存在しないときでもチェックされてしまうため、項目が1つ以上存在することを条件にするとよいでしょう。
items?.length === checkedItems.length && !!items?.length
また、全項目をチェック/解除するハンドラも作成します。
const selectAllItem = (e: ChangeEvent<HTMLInputElement>) => { if (e.target.checked) { setCheckedItems([...Array(items?.length).keys()]); } else { setCheckedItems([]); } };
CheckBoxコンポーネント
ChakraUIのCheckBox
コンポーネントを使う際にはchecked
ではなくisChecked
を使う点に注意してください。
これを間違えるとチェック状態の変更時、適切にレンダリングが発生せずバグります。
各項目のチェック
各項目のチェックに関しては、コンポーネントをitems?.map((item, i) => ...
のようにインデックス付きで取り出してあげればいいでしょう。
その際に項目ごとのonCheckハンドラを作成します。やることは自身のインデックスi
をcheckedItems
に追加するだけです。
const onCheck = (i: number, e: ChangeEvent<HTMLInputElement>) => { if (e.target.checked) { setCheckedItems((pre) => pre.concat(i)); } else { setCheckedItems((pre) => pre.filter((v) => v !== i)); } };
上記では、stateの更新をfunctional updateで行っています。Reactではstateの更新は非同期に行われるため、状態の更新は次のレンダリングタイミングで反映されます。
「以前の状態」に依存して更新する処理はこのようにfunctional updateで行う必要があります。
つまり、以下のような書き方は非推奨です。
const newItems = checkedItems.concat(i); setCheckedItems(newItems);
もちろんnewItems
がcheckedItems
に依存しない値であるような場合はfunctional updateで書かなくても問題ありません。
別コンポーネントへのuseFormの受け渡し
さて、ここまでくれば大部分の処理は問題なく実装できますが、RHFの制御を別のコンポーネントに受け渡すのに
const SampleDataRHF = useForm<{ data: SampleData[] }>(); const { reset, watch, setValue } = SampleDataRHF; // SampleDataRHFを別コンポーネントにバケツリレーで渡しまくる
というようなことをやるのはコンポーネントの責務と関係ないデータを引き回すことにも繋がり避けたいです。
このような場合は祖先となるコンポーネントをFormProvider
でラップしつつ、子孫のコンポーネントでuseFormContext()
を利用すれば、祖先となるコンポーネントのフォームデータを直接引っ張ってくることが可能です。
<FormProvider {...SampleDataRHF}> <Ancestor > /* useForm()を使用する先祖のコンポーネント */ ... <Descendant /> /* useFormContext()を使用する子孫のコンポーネント */ ... </Ancestor > </FormProvider>
おわりに
以上で、Reactでフォームを作るのに序盤で気になるようなところはおさえることができたかと思います!
……本当はもっと詳しく書きたかったのですが、本日は子供2人をワンオペしながら記事を書いているため、次回以降の記事に処理を委譲したいと思います。
なんといってもオブジェクト指向の文脈では昨今は「継承より委譲」と言われておりますし、ちゃんとした記事を書くというアドカレの流れを継承せずに、未来の自分へ委譲するということで……全てはオブジェクトだ!(最悪)
私達ACS事業部はAzure・AKSを活用した内製化のご支援をしております。ご相談等ありましたらぜひご連絡ください。
また、一緒に働いていただける仲間も募集中です!
切磋琢磨しながらスキルを向上できる、エンジニアには良い環境だと思います。ご興味を持っていただけたら嬉しく思います。