LoginSignup
16
8

More than 5 years have passed since last update.

React + Formik(+ typescript)でのフォーム実装

Last updated at Posted at 2019-03-11

背景

Reactでページフォームを作りたい。

ReduxFormが人気らしいけど、なんか難しい感じがする。

Formikというやつはどうも簡単にフォームの状態管理ができるらしい。

Formikでフォームを作ってみよう。

・・・といった感じです。

この記事は現在Formikは、参考になるサイトが少なく、
公式しかないような状態なんで、日本語の備忘録みたいなものです。

ただ、もし、似たようなことをやっている方がいれば参考になるかもしれません。

環境

  • react: 16.6.3
  • react-dom: 16.6.3
  • typescript: 3.2.2
  • formik: 1.4.2
  • yup: 0.26.10

何ができたのか

SPAで一連の入力フォームの流れを実装しました。
「入力ページ→確認ページ→(登録)→完了ページ」といった流れです。
*実際の登録処理は記述していません。

入力画面

png

バリデーションエラー

png

ちゃんと入力します

png

確認画面

png

完了画面

png

この一連の処理をテンプレートにして各フォームで使い回せるようにしました。

ファイル構成

ベースはcreate-react-appで作りました。
--scripts-version=react-scripts-tsでjsxでなく、tsx(typeScript)になるようにしてます。

src
├── App.css   // create-react-appが作ったやつ
├── App.tsx   // create-react-appが作ったやつ
├── components   // コンポーネント群
│   ├── forms   // 今回のメイン インプットフォームの部品
│   │   ├── FieldWrapper.tsx   // フォームにてFieldコンポーネントを包む(後述)
│   │   ├── FormExports.ts   // フォーム共通で扱うexportを定義
│   │   ├── FormTemplate.tsx   // フォームのテンプレートとなるもの、コア
│   │   ├── NoticeLabel.tsx   // お知らせメッセージのコンポーネント
│   │   ├── SampleForm.tsx   // サンプルとして用意したフォームの実態
│   │   ├── fields   // フォームにおけるインプットフィールド群
│   │   │   ├── FieldExports.ts   // Field共通で扱うexportを定義
│   │   │   ├── FieldTemplate.tsx   // Fieldのテンプレートとなるもの
│   │   │   ├── RadioButton.tsx   // ラジオボタン
│   │   │   └── TextInput.tsx   // テキストインプット
│   │   └── footers   // Formにおける下部
│   │       ├── ConfirmFormFooter.tsx   // 確認画面のページ下部(登録ボタン)
│   │       └── InputFormFooter.tsx   // 入力画面のページ下部(確認ボタン)
│   └── styled   // スタイル
│       └── index.tsx
├── index.css   // create-react-appが作ったやつ
├── index.tsx   // create-react-appが作ったやつ
├── logo.svg   // create-react-appが作ったやつ
└── registerServiceWorker.ts   // create-react-appが作ったやつ

基本的にはこんな感じになります。

解説

機能を一言で説明すると

「FormTemplateにFormikをHOCしたコンポーネントを渡してやれば、
入力→確認→登録といった流れのフォームをSPAで実現できる」

といったものになります。

FormTemplate

フォームのテンプレートとなるもの、コアです。

FormTemplate.tsx
import CircularProgress from '@material-ui/core/CircularProgress'
import { createStyles, withStyles, WithStyles } from '@material-ui/core/styles'
import { Theme } from '@material-ui/core/styles/createMuiTheme'
import * as React from 'react'
import ConfirmFormFooter from './footers/ConfirmFormFooter'
import InputFormFooter from './footers/InputFormFooter'
import { FormSteps, IFormProps } from './FormExports'
import NoticeLabel from './NoticeLabel'

interface IProps {
    form: React.ComponentType<IFormProps>
}

interface IState {
    id: number
    uri: string
    data: { [value: string]: string }
    otherData: { [value: string]: string }
    step: number
    isLoading: boolean
    isValid: boolean
}

/**
 * ローディングのスタイル
 * @param {Spacing} spacing
 * @returns {StyleRules<string>}
 */
const styles = ({ spacing }: Theme) =>
    createStyles({
        progress: {
            margin: spacing.unit * 2
        }
    })

/**
 * フォームテンプレートのコンポーネント
 */
class FormTemplate extends React.PureComponent<
    IProps & WithStyles<typeof styles>,
    IState
> {
    /**
     * stateの初期化
     */
    public state = {
        id: 0,
        uri: '',
        data: {},
        otherData: {},
        step: FormSteps.INPUT,
        isLoading: true,
        isValid: false
    }

    /**
     * 前処理
     * APIで必要なデータを取得するなど
     */
    public componentDidMount() {
        // APIで必要なデータを取得するならばここに記述

        // 例えばフォームでデータ更新処理をさせたい時に更新対象のデータを取ってくるなど
        // stateのdataがフォームと対応するデータ
        // this.setState({ data: getData })

        // それ以外のマスタデータなどを持ってきたい時はotherDataに入れておく
        // this.setState({ otherData: getOtherData })

        // APIでデータ取得後にローディングを終了させる
        this.setState({ isLoading: false })
    }

    /**
     * ローディング、メッセージボックス、メインコンテンツ、フッターのレンダリング
     */
    public render() {
        return (
            <>
                {this.state.isLoading ? (
                    <div>
                        <CircularProgress
                            className={this.props.classes.progress}
                        />
                    </div>
                ) : (
                    <>
                        {this.state.step === FormSteps.COMPLETE &&
                            [
                                this.state.isValid ? (
                                    <NoticeLabel value="成功" key="notice_success" />
                                ) : (
                                    <NoticeLabel value="失敗" type="fail" key="notice_fail" />
                                )
                            ]
                        }
                        <this.props.form
                            data={this.state.data}
                            otherData={this.state.otherData}
                            step={this.state.step}
                            onSubmit={this.handleSubmit}
                        />
                        <div className="form__footer">
                            {this.state.step === FormSteps.INPUT ? (
                                <InputFormFooter />
                            ) : this.state.step === FormSteps.CONFIRM ? (
                                <ConfirmFormFooter
                                    onResetStep={this.resetStep}
                                />
                            ) : null}
                        </div>
                    </>
                )}
            </>
        )
    }

    /**
     * フォームのsubmit時に呼ばれる関数
     * FormikのHOCで呼ばれるようにしている
     */
    private handleSubmit = (values: any) => {
        if (this.proceedStep() === FormSteps.COMPLETE) {
            // 最終処理
            // 例えば、valuesに入力した値がセットされているのでそれをPOSTするとかの処理

            // 出力例: {name: "名無しの権兵衛", gender: "0"}
            console.log(values)

            // 処理が成功すれば成功したNoticeLabelを表示させる
            this.setState({ isValid: true })
        }
    }

    /**
     * フォームのStepを次の段階に変更する
     */
    private proceedStep = () => {
        this.setState({
            step: this.state.step ? this.state.step + 1 : FormSteps.INVALID
        })
        return this.state.step
    }

    /**
     * フォームのStepを初期の状態に戻す
     */
    private resetStep = () => {
        this.setState({ step: FormSteps.INPUT })
    }
}

export default withStyles(styles)(FormTemplate)

以下のようにFormikをHOCしたコンポーネントをプロパティに渡してやることで呼び出しています。

<FormTemplate form={SampleForm} />

FormikをHOCしたコンポーネントに対して、フォームのステップや、
データの入出力処理を各フォームごとに用意する必要はなく、
こちらのテンプレートでまとめておくことができます。

サブフォームコンポーネント

フォームの実態です。

例えば企業情報フォームでは

  • 企業名、電話番号とかを入力する
  • 電話番号はハイフンありでないと弾く

とかそういった実態を定義するコンポーネントです

以下、氏名をテキストボックスで、性別をラジオボタンで入力できるフォームの例です。

SampleForm.tsx
import { FastField, Form, FormikProps, withFormik } from 'formik'
import * as React from 'react'
import * as Yup from 'yup'
import FieldTemplate from './fields/FieldTemplate'
import RadioButton, { IRadioButtonItem } from './fields/RadioButton'
import TextInput from './fields/TextInput'
import FieldWrapper from './FieldWrapper'
import { IFormProps } from './FormExports'

interface IFormValue {
    name: string
    gender: string
}

/**
 * 性別(ラジオボタン)のアイテム
 */
const genderItems: IRadioButtonItem[] = [
    { id: 0, name: '' },
    { id: 1, name: '' },
    { id: 2, name: '回答なし' },
]

/**
 * フォーム
 */
const InnerForm = React.memo<FormikProps<IFormValue> & IFormProps>(props => (
    <Form id="form" className="form">
        <FieldWrapper step={props.step} setFieldValue={props.setFieldValue}>
            <FieldTemplate label="氏名" name="name" required={true}>
                <FastField component={TextInput} />
            </FieldTemplate>
            <FieldTemplate label="性別" name="gender" required={true}>
                <FastField component={RadioButton} items={genderItems} />
            </FieldTemplate>
        </FieldWrapper>
    </Form>
))

/**
 * FormikをHOCしたフォーム
 */
const MainForm = withFormik<IFormProps, IFormValue>({
    mapPropsToValues: props => ({
        name: props.data.name || '',
        gender: props.data.gender || 0,
    }),
    validationSchema: Yup.object().shape({
        name: Yup.string()
            .max(20, '氏名は20文字以内にしてください。')
            .required('氏名を入力してください'),
        gender: Yup.string().required('性別を入力してください'),
    }),
    handleSubmit: (values, { props, setSubmitting }) => {
        if (props.onSubmit) {
            props.onSubmit(values)
        }
        setSubmitting(false)
    }
})(InnerForm)

export default MainForm

こんな感じでフォームの実態である以下を定義していきます。

  • レンダリングするコンポーネント
  • withFormikというHOCを使う
    • mapPropsToValuesで初期データを定義(例えばdateが与えられていればそのデータを、なければ初期データを)
    • validationSchemaでYupを利用したバリデーション
    • handleSubmitでsubmit時の処理を制御している(例では親から渡されたonSubmitを実行している)

FieldWrapper

こいつはFormとFieldの橋渡しするやつです

FieldWrapper.tsx
import * as React from 'react'

interface IProps {
    step?: number
    setFieldValue?: (
        arg1: string,
        arg2: number | string | object | File
    ) => void
}

const FieldWrapper: React.FC<IProps> = props => {
    const { children, setFieldValue, step } = props

    const childrenWithProps = React.Children.map(children, child => {
        const element = child as React.ReactElement<any>
        return element
            ? React.cloneElement(element, {
                  setFieldValue,
                  step
              })
            : null
    })

    return <div>{childrenWithProps}</div>
}

export default React.memo(FieldWrapper)

要は、各フィールドに対して、step(現在フォームはどの状態か)setFieldValueという関数を渡しています。

setFieldValueは簡単にいうと
各フィールドのvalueをフォームがPOSTすることになるvalueとしてwithFormik内で扱えるようにしてくれてます

この辺は私が正しく説明できるレベルではないので、すみませんが、公式で補足すると良いかと思います。

TextInput

例としてFieldのコンポーネントを一つ提示します。

TextInput.tsx
import * as React from 'react'
import { TextField } from '../../styled'
import { IFieldProps } from './FieldExports'

/**
 * テキスト入力のプロパティのインターフェース
 */
interface ITextInputProps extends IFieldProps {
    type?: string
    prefix?: string
}

/**
 * テキスト入力のStateのインターフェース
 */
interface ITextInputState {
    value: string
}

/**
 * テキスト入力
 */
export default class TextInput extends React.Component<
    ITextInputProps,
    ITextInputState
> {
    /**
     * Stateの初期値をセット
     *
     * @type {{value: any}}
     */
    public state: ITextInputState = { value: this.props.field.value }

    /**
     * レンダリング
     */
    public render() {
        const { readonly, prefix, type } = this.props
        const { value } = this.state
        return (
            <>
                {prefix || null}
                {readonly ? (
                    <label>{value}</label>
                ) : (
                    <TextField
                        value={value}
                        type={type || 'text'}
                        onChange={this.handleChange}
                        onBlur={this.handleBlur}
                    />
                )}
            </>
        )
    }

    /**
     * コンポーネントを際描写する必要があるかを判定
     *
     * @param {ITextInputProps} nextProps
     * @param {ITextInputState} nextState
     * @returns {boolean}
     */
    public shouldComponentUpdate(
        nextProps: ITextInputProps,
        nextState: ITextInputState
    ) {
        return (
            this.props.readonly !== nextProps.readonly ||
            this.state.value !== nextState.value
        )
    }

    /**
     * テキスト入力の値が変わった時実行
     */
    private handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        this.setState({
            value: event.currentTarget.value
        })
    }

    /**
     * テキスト入力からフォーカスが外れた時実行
     *
     * @param {React.ChangeEvent<HTMLInputElement>} event
     */
    private handleBlur = (event: React.ChangeEvent<HTMLInputElement>) => {
        if (this.props.setFieldValue) {
            this.props.setFieldValue(
                this.props.field.name,
                event.currentTarget.value
            )
        }
    }
}

このように各フィールドでは渡されてきたsetFieldValueを使って値を更新してやります。
こうすることでwithFormikで値の変更を察知することができるようになります。

shouldComponentUpdateはレンダリングの回数を減らすためにやってます

まとめ

といったところがコアのところになります。
これによっていちいち機能ごとに各状態の管理や画面を用意してやることもなく、
FormテンプレートにHOCしたフォームコンポーネントを渡してやるだけで
一連の流れを持った機能を使えるようになります。

説明が長くなる上にかなりニッチな気がするのでこの辺でやめておきます。
Githubにソースあげておきますので必要ならばご参考ください

なお、MATERIAL-UIのコンポーネントも利用しているので、ちょっと複雑になっています。
また、Dockerコンテナに乗っているため、その手のファイルも含まれていますのでご留意ください。

16
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
8