TypeScript
React
Formik

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


背景

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コンテナに乗っているため、その手のファイルも含まれていますのでご留意ください。