投稿日:2021年6月23日
この記事ではformikを使用したカスタムコンポーネントの作成方法について詳しく解説しています。
この記事はPart2の続きになります。
今回はformikを使用したカスタムコンポーネントの作成方法について詳しく解説していきます。
自分でカスタムなFormikのカスタムコンポーネントを作成するにはuseField()を使いましょう。この関数は<Field />の様にコンテキストを簡単に利用できるオブジェクトを取得するためのReactHookです。関数の型は以下の様になっています。
function useField<Val = any>(propsOrFieldName: string | FieldHookConfig<Val>): [FieldInputProps<Val>, FieldMetaProps<Val>, FieldHelperProps<Val>];
この関数の引数にはuseField("firstname")
の様に直接プロパティ名を指定するか、以下のプロパティを持ったオブジェクトを渡して使用します。
次にuseField()の返り値についてすごく簡単に解説します。
※さらに詳しく知りたい方はそれぞれのリンク先のドキュメントを読んだり、IDEで型を分解してみてください!
では実際に以下のような機能を持ったカスタムのフォームを作成してみます。
import React from "react";
import { Formik, useField, FieldHookConfig } from "formik";
import * as Yup from "yup";
/**
* フォームの型
*/
type FormValueType = {
email: string;
firstName: string;
lastName: string;
};
/**
* [ props ] MyTextFieldのプロパティ
*/
type MyTextFieldProps = FieldHookConfig<string> & {
label: string;
};
/**
* [ component ] カスタム入力フォーム
* labelにはラベル内のコンテンツとして表示する文字列を渡し、
* nameには対象プロパティ名を渡します
* @param param0
* @returns
*/
const MyTextField = ({ label, ...props }: MyTextFieldProps) => {
const [field, meta, helper] = useField(props);
return (
<>
<label htmlFor={props.id || props.name}>{label}</label>
<input className="text-input" {...field} />
{meta.touched && meta.error ? (
<div className="error">{meta.error}</div>
) : null}
</>
);
};
/**
* バリデーションスキーマ
*/
const validationSchema = Yup.object({
firstName: Yup.string()
.max(15, "15文字以下で入力してください")
.required("このフィールドは必須です"),
lastName: Yup.string()
.max(15, "15文字以下で入力してください")
.required("このフィールドは必須です"),
email: Yup.string()
.email("正しいメールアドレスを入力してください")
.required("このフィールドは必須です"),
});
/**
* 送信処理
* @param values
*/
const onSubmit = (values: FormValueType) => {
alert(JSON.stringify(values, null, 2));
};
/**
* 登録フォーム
*/
const SignupForm = () => {
const initialValues: FormValueType = {
email: "",
firstName: "",
lastName: "",
};
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={onSubmit}
>
{(formik) => {
return (
<form onSubmit={formik.handleSubmit}>
<MyTextField label="メールアドレス" name="email" type="email"/>
<MyTextField label="姓" name="lastName" type="text"/>
<MyTextField label="名" name="firstName" type="text"/>
<button type="submit">送信</button>
</form>
);
}}
</Formik>
);
};
export default SignupForm;
まずはコンポーネントが受け取るpropsの型の定義であるMyTextFieldPropsに注目してみましょう。この型はFieldHookConfig<string>と{label: string;}の交差型(Intersection Types)になっています。カスタムフォームの作成に最低限必要なpropsの型はFieldHookConfig<V>ですが、今回は『ラベルに表示する文字列を指定できる』という機能を作成するために、labelというキーでラベルに表示する文字列を受け取れる型にしています。
次にMyTextFieldに注目してください。このコンポーネントはプロパティでMyTextFieldProps型の値をオブジェクトの分割代入(Destructuring assignment) でlabelとそれ以外に分けて受け取っています。propsはFieldHookConfig<string>の型になっているためuseField()にそのまま渡すことができます。
ちなみにuseField()の返り値の受け取り方も配列の分割代入です。今回使用しなかったhelper: FieldHelperProps<string>はこのコンポーネント内で使用していないのでconst [field, meta] = useField(props);
で良いのですが、わかりやすさのためにすべて展開しました(実際のコードでは省略してください)。
コンポーネントのTSX部分は詳しい説明はいらないと思います。field: FieldInputProps<string>にはFormik内のフォームとして入力要素に渡すべきプロパティが詰まっているので、{...filed}というスプレッド構文で渡しています。meta: FieldMetaProps<string>もPart2で説明したエラーテキスト表示の使い方と同じですね。
カスタムフォームの作成も以外と簡単ですね!!
さて、ここまでのコードは既成の<Field>コンポーネントと比較して足りない機能が一つあります。
実は<Field>コンポーネントは<input>が受け取れる属性をすべて受け取ることができ、その属性を自動的に<input> に渡してくれるのです。
<Field name="email" type="email" onClick={()=>{console.log("クリックされました")}}/>
UIライブラリを使用したことがある方は、『できて当たり前じゃないの?』と思ってしまうかもしれません。
ではuseField()を使ったカスタムコンポーネントの実装の場合どうでしょう?
実は前の章で<input>に展開したfield: FieldInputProps<string>にはFormikのコンテキストと値をやり取りするためのプロパティしか存在していないため、いくら<MyTextField>にonClick()やonKeyDown()などを渡しても途中で捨てられてしまうのです。
正直、汎用性を考えるとこのような実装はあまり良くありませんね。
しかしこんな問題、MyTextFieldが受け取ったpropsをそのまま展開して<input>に渡して上げれば解決できるのでは?
ということで実際にやってみましょう。
// [...]
/**
* [ props ] MyTextFieldのプロパティ
*/
type MyTextFieldProps = FieldHookConfig<string> & {
label: string;
};
/**
* [ component ] カスタム入力フォーム
* labelにはラベル内のコンテンツとして表示する文字列を渡し、
* nameには対象プロパティ名を渡します
* @param param0
* @returns
*/
const MyTextField = ({ label, ...props }: MyTextFieldProps) => {
const [field, meta, helper] = useField(props);
return (
<>
<label htmlFor={props.id || props.name}>{label}</label>
<input className="text-input" {...field} />
{meta.touched && meta.error ? (
<div className="error">{meta.error}</div>
) : null}
</>
);
};
// [...]
果たしてこれで良いのでしょうか????
実はこのコードは以下のエラーで終了してしまいます!!!
/home/ogihara/Projects/react_ts/formik_sample/app/src/SignupForm.tsx
TypeScript error in /home/ogihara/Projects/react_ts/formik_sample/app/src/SignupForm.tsx(34,14):
Type '{ ref?: LegacyRef<HTMLInputElement> | undefined; key?: Key | null | undefined; accept?: string | undefined; alt?: string | undefined; autoComplete?: string | undefined; ... 287 more ...; innerRef?: ((instance: any) => void) | undefined; } | { ...; } | { ...; }' is not assignable to type 'DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>'.
Type '{ ref?: LegacyRef<HTMLSelectElement> | undefined; key?: Key | null | undefined; autoComplete?: string | undefined; autoFocus?: boolean | undefined; ... 267 more ...; checked?: boolean | undefined; }' is not assignable to type 'DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>'.
Type '{ ref?: LegacyRef<HTMLSelectElement> | undefined; key?: Key | null | undefined; autoComplete?: string | undefined; autoFocus?: boolean | undefined; ... 267 more ...; checked?: boolean | undefined; }' is not assignable to type 'ClassAttributes<HTMLInputElement>'.
Types of property 'ref' are incompatible.
Type 'LegacyRef<HTMLSelectElement> | undefined' is not assignable to type 'LegacyRef<HTMLInputElement> | undefined'.
Type '(instance: HTMLSelectElement | null) => void' is not assignable to type 'LegacyRef<HTMLInputElement> | undefined'.
Type '(instance: HTMLSelectElement | null) => void' is not assignable to type '(instance: HTMLInputElement | null) => void'.
Types of parameters 'instance' and 'instance' are incompatible.
Type 'HTMLInputElement | null' is not assignable to type 'HTMLSelectElement | null'.
Type 'HTMLInputElement' is missing the following properties from type 'HTMLSelectElement': length, options, selectedIndex, selectedOptions, and 4 more. TS2322
エラー内容を簡単に解説すると、『<input>に受け取れる属性以外の属性を渡そうとしているよ!』という内容になっています。
では、propsの型FieldHookConfig<string>は一体どのような型なのでしょうか?ソースコードを見てみると以下の様に定義されています。
type FieldHookConfig<T> = GenericFieldHTMLAttributes & FieldConfig<T>;
GenericFieldHTMLAttributesとFieldConfig<T>の交差型(Intersection Types)ですね。ここで問題になるのがGenericFieldHTMLAttributesです。これは以下のような定義になっています。
declare type GenericFieldHTMLAttributes = JSX.IntrinsicElements['input'] | JSX.IntrinsicElements['select'] | JSX.IntrinsicElements['textarea'];
そう!!
じつはGenericFieldHTMLAttributes型は『<input>、<select>、<textarea>すべての要素の属性受け取り可能だよ〜』という型だったのです。
型に厳格なTypeScriptは『<input>に<select>や<textarea>にユニークな属性を渡そうとしている?』と考えエラーを出していたのですね。
解決策は実に簡単です。
MyTextFieldPropsの型に使用したFieldHookConfig<T>を分解して、どの入力要素の属性を受け取るのか明確に指定してあげればOKです!!
// [...]
/**
* [ props ] MyTextFieldのプロパティ
* 入力要素をinputに限定
*/
type MyTextFieldProps = JSX.IntrinsicElements['input'] & FieldConfig<string> &{
label: string;
};
/**
* [ component ] カスタム入力フォーム
* labelにはラベル内のコンテンツとして表示する文字列を渡し、
* nameには対象プロパティ名を渡します
* @param param0
* @returns
*/
const MyTextField = ({ label, ...props }: MyTextFieldProps) => {
const [field, meta ] = useField(props);
return (
<>
<label htmlFor={props.id || props.name}>{label}</label>
<input className="text-input" {...field} {...props}/>
{meta.touched && meta.error ? (
<div className="error">{meta.error}</div>
) : null}
</>
);
};
// [...]
更に正確に作成したいのであれば、オブジェクトの分割代入で<input>の属性のみを取得して渡すほうが良いですね。
// [...]
const MyTextField = ({ label, ...props }: MyTextFieldProps) => {
const [field, meta ] = useField(props);
// inputの属性のみを抽出
const {component, as, render, children, validate, name, type, value, innerRef, ...rest} = props;
return (
<>
<label htmlFor={props.id || props.name}>{label}</label>
<input className="text-input" {...field} {...rest}/>
{meta.touched && meta.error ? (
<div className="error">{meta.error}</div>
) : null}
</>
);
};
// [...]
補足
今回propsをスプレッド構文でinputに渡すことに非常に苦労しましたね。
実はJavaScriptでカスタムコンポーネントを作成した場合には特にエラーもなくそのまま{...props}でinputに渡せたりします。(参考)
formikのようなJavaScriptが前提のライブラリのドキュメントでは、しばしばTypeScriptでの実装方法の探索で迷子になることがあるのです。