React Hook Form でAPIでのget/postを見越した入力フォームを作る際の勘所
フロントエンドエンジニアの茶木です。
さくさく動くフォームのいいですよね。そこで React Hook Form の登場です。
実務で使うときは getApi を受けたり postApi で送信したりと連携が必要になってきます。この辺を踏まえてどうコーディングするか見えてきた感があるのでまとめます。
目次
- ファイル構成
- useFormで管理する型を決める
- useController を書く
- バリデーションのスキーマを書く
- 複数のフィールドの値をまたいでバリデーションする
ファイル構成
- NewForm.tsx 新規フォーム
- EditForm.tsx 編集フォーム
- Form.tsx フォーム(新規と編集で共通)
- FormController.tsx フォームのフィールド
- validator.ts バリデーション
// 新規の空のフォームを作る
export const NewForm = () => {
const submit = useCallback(async (location: Location) => postApi(location));
const defaultValues: FormInputs = {
title: "",
address: "",
coordinates: {
longitude: 35.0;
latitude: 139.0;
},
isAutocomplete: false;
}
return (
<Form defaultValues={defaultValues} submit={submit}
);
}
// apiから呼び出して埋めたの編集用のフォームを作る
interface Props = {id: string}
export const EditForm = ({ id }: Props) => {
const submit = useCallback(async (location: Location) => putApi(location));
const [defaultValues, setDefaultValues] = useState<FormInputs>();
useEffect(()=>{
(async () => {
const location = await getApi(id));
setDefaultValues({location, isAutocomplete: false});
})();
},[]);
if(!defaultValues) return <>Loading...<>
return (
<Form defaultValues={defaultValues} submit={submit}
);
}
// 共通のフォーム
interface Props = {defaultValues: FormInputs; submit: (location: Location) => void;}
export const Form = ({defaultValues, submit}: Props) => {
const { control } = useForm<FormInputs>({
resolver: yupResolver(validatorSchema),
mode: "onChange",
defaultValues,
});
return (
<FormController control={control} submit={submit} />
);
}
validator.ts
と FormController.tsx
のソースは以降で順に挙げていきます
useForm
で管理する型を決める
useForm
を使うときは以下のようになります。
この useForm
で管理するフォームのフィールドの型 FormInputs
(型名は任意)をどのような形にするかで快適さが8割決まると言っても良いです。
const { control, trigger } = useForm<FormInputs>({
resolver: yupResolver(validatorSchema),
mode: "onChange",
defaultValues,
});
getApi
を想像して FormInputs
を設計する
たとえば、ロケーションを入力するフォームがあるとします。
interface FormInputs {
title: string;
address: string;
isAutocomplete: boolean;
longitude: number;
latitude: number;
}
こんな風にフォームの形をそのまま型にトレースしたくなるかもしれませんが、getApi
や postApi
で受け取ったり渡したりするならば、加工が手間になるケースが想定されるので、オブジェクトの形でそのまま FormInputsにもたせます。 api
に含まないフィールドはバラして FormInputs
にもたせます。この例だと isAutocomplete
ですね。
// APIで受け取ったり渡したりする型
interface Location {
id?: string;
address: string;
coordinates: {
longitude: number;
latitude: number;
}
}
interface FormInputs {
shopLocation: Location;
isAutocomplete: boolean;
}
useController
を書く
先にソースを示します。
interface Props = {control: Control<FormInputs>; submit: (location: Location) => void;}
export const Form = ({control, submit}: ControllerProps) => {
const location = useController({ control, name: "location" });
const title = useController({ control, name: "location.title" });
const address = useController({ control, name: "location.address" });
const coordinates = useController({ control, name: "location.coordinates" });
const longitude = useController({ control, name: "location.coordinates.longitude" });
const latitude = useController({ control, name: "location.coordinates.latitude" });
const isAutocomplete = useController({ control, name: "isAutocomplete" });
return (
<>
<label>
地名 <TextField {...title.field}>
{title.fieldState.error?.message}
<label>
<label>
住所 <TextField {...address.field}>
{address.fieldState.error?.message}
<label>
<label>
住所から自動で取得する <Checkbox {....isAutocomplete.field}>
<label>
<label>緯度 <TextField {...longitude.field}><label>
<label>経度 <TextField {...latitude.field}><label>
{coordinates.fieldState.error?.message}
<button
onClick={() => submit(location.field.value)}
disabled={Boolean(location.fieldState.error)}
>保存</button>
</>
);
}
まず、注目してほしいのは、name: "location"
で location
が取れるのは当然として、 title
が name: "location.title"
という.
のチェーン記法で取得できる点です。このため useForm
で オブジェクトの形で渡しても大丈夫なのです。
const location = useController({ control, name: "location" });
const title = useController({ control, name: "location.title" });
続いて、値の渡し方とエラーメッセージについてです。
{…title.field}
は onChange={title.field.onChange} defaultValue={title.field.defaultValue}
を意図してスプレッド記法で書いています。(つまり他にも name
や onBlur
など他の値も渡っています)
そして title.fieldState.error?.message
がバリデーション結果の message
になります
<label>
地名 <TextField {...title.field}>
{title.fieldState.error?.message}
<label>
続いて submit
の箇所です。
location.field.value
を submit
に渡すことで、api
に必要な props
をすべて渡せています。id
など フォームのフィールドに存在しないが渡さなくてはいけないものを含み、isAutocomplete
などフォームのフィールドではあるが apiに渡さないものが省かれます。これが useForm
で api
でやりとりする Location
をそのまま渡した理由です。
さらに、disabled
です。location.fieldState.error
は自身はもちろん、下位の title
や address
で起きたエラーがあれば保持し、エラーがないときは undefined
になるのでそのまま ボタンの disabled
に使えます。
<button
onClick={() => submit(location.field.value)}
disabled={Boolean(location.fieldState.error)}
>保存</button>
バリデーションのスキーマを書く
export const validatorSchema = yup.object({
location: yup.object({
title: yup
.string()
.max(
60,
"地名は${max}文字以内で入力してください"
)
.required("地名を入力してください"),
address: yup
.string()
.required("住所を入力してください"),
coordinates: yup
.object()
.test(
"coordinates",
"国内の緯度経度である必要があります",
(coordinates) => isDomestic(coordinates)
),
}),
isAutocomplete: yup
.bool()
.test(
"coordinates-empty",
"緯度・経度を入力する必要があります",
(isAutocomplete, context) => {
if (isAutocomplete) return true;
const { longitude, latitude } = context.parent.location.coordinates;
return longitude && latitude;
}
),
});
バリデーションは Yup を使います。required
や最大文字数などは用意されているので簡単に判定できます。このようにして作った schema
を useForm
を作るときに yupResolver
を通して resolver
に渡してやります。
複数のフィールドの値をまたいでバリデーションする
同一の親を持つフィールドの値をまたいでバリデーションする(研究中)
以下は、最適解を模索中のものです。
たとえば、緯度・経度の両方を見て、日本国内でなければエラーを出すといったような場合です。
これは longitude
と latitude
をメンバーに持つ親の coordinates
で判定を書けば良く、フォームのフィールドは、それぞれ 緯度経度の controller
を、エラーの表示箇所には coordinates
の controller
を使えば良さそうですね。
coordinates: yup
.object()
.test(
"coordinates",
"国内の緯度経度である必要があります",
(coordinates) => isDomestic(coordinates)
),
と、思ったのですが、以下のようにして coodinates.field
の onChange
を通して値を書き換えないと、エラーメッセージが更新されませんでした。 これだとちょっと使いにくいですね・・・
<label>緯度 <TextField
defaultValue={longitude.field.value}
onChange={(e) => coodinates.field.onChange({
...coodinates.field.value,
longitude: e.target.value
})}
><label>
<label>経度 <TextField
defaultValue={latitude.field.value}
onChange={(e) => coodinates.field.onChange({
...coodinates.field.value,
latitude: e.target.value
})}
><label>
同一の親を持たないフィールドの値をまたいでバリデーションする(研究中)
こちらもきれいな解決法が、見いだせていないケースです。
たとえば、緯度経度は入力必須だが、isAutocomplete
が true
のときは入力が不要になるといった場合です。
isAutocomplete
を coodinates
の配下に入れてしまえば、 coordinates
のバリデーションとして書けるのですが、api
にわたす前に、isAutocomplete
を除去する一手間がかかるようになるので、いい方法がないかと模索しています。
isAutocomplete: yup
.bool()
.test(
"coordinates-empty",
"緯度・経度を入力する必要があります",
(isAutocomplete, context) => {
if (isAutocomplete) return true;
const { longitude, latitude } = context.parent.location.coordinates;
return longitude && latitude;
}
),
上記は、一案で text
の context
から context.parent
と登ることで他のフィールドにアクセスできるので、 isAutocomplete
から 緯度経度を見に行き判定をしています。
ただし、これは問題が2つあります。
ひとつは、緯度経度のバリデーションは、 coordinates
に書くのが自然だと思われるのですが、 isAutocomplete
の方に書いている点です。なぜかというと、coordinates
の parent
からは isAutocomplete
にたどり着けないからです。(parent
のチェーンはできない)
ふたつめは、もっと深刻で 緯度経度の onChange
では isAutocomplete
のバリデーションが走らないことです。解決方法のひとつは useForm
の際に trigger
を取得して、 trigger("isAutocomplete")
を 緯度経度の onChange
の際に手動で呼んでやることですが、いまいちスマートじゃないですよね
・・・うむむ。
おわりに
フォームとフォームのバリデーションは奥が深いですね!
ひきつづき研究をしていきます!
Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています
弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?
もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!
求人応募してみる!