※このブログではサーバー運用、技術の検証等の費用のため広告をいれています。
記事が見づらいなどの問題がありましたらContactからお知らせください。

<前のページ
【React + TypeScript】formikで高機能フォームを作成|Part2

【React + TypeScript】formikで高機能フォームを作成|Part3

react web開発 TypeScript formタグ formik

投稿日:2021年6月23日

このエントリーをはてなブックマークに追加
この記事ではformikを使用したカスタムコンポーネントの作成方法について詳しく解説しています。

はじめに

この記事について

この記事はPart2の続きになります。
今回はformikを使用したカスタムコンポーネントの作成方法について詳しく解説していきます。

環境について

  • node v10.19.0
  • npm v6.14.4
  • react v17.0.2
  • bashのターミナル

参考URL


実践

useField()について

自分でカスタムなFormikのカスタムコンポーネントを作成するにはuseField()を使いましょう。この関数は<Field />の様にコンテキストを簡単に利用できるオブジェクトを取得するためのReactHookです。関数の型は以下の様になっています。

function useField<Val = any>(propsOrFieldName: string | FieldHookConfig<Val>): [FieldInputProps<Val>, FieldMetaProps<Val>, FieldHelperProps<Val>];

この関数の引数にはuseField("firstname")の様に直接プロパティ名を指定するか、以下のプロパティを持ったオブジェクトを渡して使用します。

  • name : string - プロパティ名(必須
  • validate?: (value: any) => undefined | string | Promise<any> - バリデーション関数(オプション
  • type?: string - 対象HTMLフォームのタイプ(オプション
  • multiple?: boolean - 複数の値を扱うか否か(オプション
  • value?: string - checkboxやradioボタンでのみ有効なオプション。submit時に値がこのキーで渡される

次にuseField()の返り値についてすごく簡単に解説します。

※さらに詳しく知りたい方はそれぞれのリンク先のドキュメントを読んだり、IDEで型を分解してみてください!

  • FieldInputProps<Value>:Formikのコンテキストを利用するために、入力フォームに渡す属性値がまとめられたオブジェクト
  • FieldMetaProps<Value>:エラーや入力状態など入力フォームの状態がまとめられたオブジェクト
  • FieldHelperProps<Value>:値やエラーや入力状態を能動的に扱う関数がまとめられたオブジェクト

簡単なカスタムフォーム

では実際に以下のような機能を持ったカスタムのフォームを作成してみます。

  • ラベルに表示する文字列を指定できる
./SignupForm.tsx
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とそれ以外に分けて受け取っています。propsFieldHookConfig<string>の型になっているためuseField()にそのまま渡すことができます。

ちなみにuseField()の返り値の受け取り方も配列の分割代入です。今回使用しなかったhelper: FieldHelperProps<string>はこのコンポーネント内で使用していないのでconst [field, meta] = useField(props);で良いのですが、わかりやすさのためにすべて展開しました(実際のコードでは省略してください)。

コンポーネントのTSX部分は詳しい説明はいらないと思います。field: FieldInputProps<string>にはFormik内のフォームとして入力要素に渡すべきプロパティが詰まっているので、{...filed}というスプレッド構文で渡しています。meta: FieldMetaProps<string>Part2で説明したエラーテキスト表示の使い方と同じですね。

カスタムフォームの作成も以外と簡単ですね!!

propsをフォームに展開

さて、ここまでのコードは既成の<Field>コンポーネントと比較して足りない機能が一つあります。

実は<Field>コンポーネントは<input>が受け取れる属性をすべて受け取ることができ、その属性を自動的に<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>;

GenericFieldHTMLAttributesFieldConfig<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です!!

./SignupForm.tsx
// [...]

/**
 * [ 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>の属性のみを取得して渡すほうが良いですね。

./SignupForm.tsx
// [...]

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での実装方法の探索で迷子になることがあるのです。

このエントリーをはてなブックマークに追加

<前のページ
【React + TypeScript】formikで高機能フォームを作成|Part2

関連記事

記事へのコメント