こんにちはdohdonです。
今回はReactでのフォーム作成についての記事です。
下記に当てはまる人が対象です。
- Reactでフォームを作りたい
- フォームの管理をストアに持たせたくない。
- プラグインも使いたく無い
hooksを利用して作るフォームが状態管理の切り分けができて良かったのと
バリデーション実装ロジックでアハ体験があったので記事にしました。
フォームの実装(バリデーション無し)
さっそくフォームを作ってみましょう。
バリデーションをかけない場合は入力情報の保存・更新ができればOKです。
さっそくuseStateで実装してみます。
今回はログインフォームを作ってみましょう
よくあるメールとパスワードを入力してログインする形ですね。
import React, { useState } from 'react'
const Example = ({onSubmit}) => {
[state, setState] = useState({
mailaddress: '',
password: '',
})
const submit = () => onSubmit({
mailaddress: state.mailAddress,
password: state.password
})
const updateMailaddress = (ev) => setState({
...state
mailaddress: ev.target.value
})
const updatePassword = (ev) => setState({
...state
password: ev.target.value
})
return (
<div>
// メールアドレス
<input value={state.mailaddress} onChange={updateMailaddress}/>
// パスワード
<input type="password" value={state.password} onChange={updatePassword}/>
// 決定ボタン
<button onClick={submit}>ログインする</button>
</div>
)
}
簡易のフォームであればこんな形になるかと思います。
ただ、ログインフォームで情報を空でサーバーに渡すわけにもいきませんし、やはりバリデーションが必要です。
サーバー側からエラーを返してもらって表示するという形も想定されますが
無用な通信を発生させないためにも最低限フロント側でバリデーションはかけたいところです。
バリデーションを実装する上での情報設計
実際にバリデーション機能付きのフォームを作る上で必要な項目を簡単に説明をしていきます。
必要な情報は次のものです。
- 入力情報
- エラー情報
- エラー表示判定情報
入力情報
ユーザーが入力した情報が格納されます。
コンポーネント側ではinput要素のvalueの値となります。
エラー情報
表示するエラーメッセージを入れます。
今回は必須チェックのみとするので「必須です」が入ります。
エラーがないときはnullを入れます。
コンポーネント側では格納されたエラーがある時エラーを表示します。
エラー表示判定
その名の通りエラーを表示するかどうかを判定する部分です。
ユーザーがフォーカスを外した時、決定ボタンが押された時、エラーを表示したいタイミングは一律ではありません。
コンポーネント側ではここがtrueであればエラーを表示するようにします。
以上の構造を鑑みて管理するデータ構造は次のよう型定義になります。
interface State {
// 入力情報
inputs: {
mailaddress: string
password: string
}
// エラー情報
errors: {
mailaddress: string | null
password: string | null
}
// エラー表示判定(今回はユーザーが触れたかどうかで表示判定したいためtouchedと名付けます)
touched: {
mailaddress: boolean
password: boolean
}
}
入力・エラー・エラー表示判定はそれぞれ同じKeyを持っていることが確認できますね。
フォームの実装(バリデーション有り)
先ほどはuseStateを使っていましたが、構造が複雑になってきたのでuseReucerを使います。
型定義があったほうが分かりやすいので、今回はtypescriptで記述していきます。
状態管理用の外部ファイルを作成
import { useReducer } from 'react'
interface Action<T, P, E = {}> {
type: T;
payload: P;
error?: E;
}
type PatchLogin = Action<'PATCH_LOGIN', Partial<{
mailaddress: string
password: string
}>>
type TouchAll = Action<'TOUCH_ALL', undefined>
type Actions = PatchLogin | TouchAll
interface State {
inputs: {
mailaddress: string
password: string
}
errors: {
mailaddress: string | null
password: string | null
}
touched: {
mailaddress: boolean
password: boolean
}
}
// バリデーション: 必須チェック
const required = (v) => {
return v === null || v === undefined || v === '' || typeof v === 'number' && isNaN(v) ? '必須です' : null
}
const reducer = (state: State, action: Actions): State => {
switch (action.type) {
case 'PATCH_LOGIN':
inputs {
inputs: { ...state.inputs, ...action.payload },
errors: {
...state.errors,
mailaddress:
action.payload.mailaddress || action.payload.mailaddress === ''
? required(action.payload.mailaddress)
: required(state.inputs.mailaddress),
password:
action.payload.password || action.payload.password === ''
? required(action.payload.password)
: required(state.inputs.password)
},
touched: {
...state.touched,
[Object.keys(action.payload)[0]]: true
}
}
case 'TOUCH_ALL':
return {
...state,
touched: {
...state.touched,
...Object.keys(state.touched).reduce(
(o, k) => ({ ...o, [k]: true }),
{}
)
}
}
}
}
export const useLoginForm = (initialState?: Login) =>
useReducer(reducer, {
inputs: initialState || {
mailaddress: '',
password: ''
},
errors: {
mailaddress: null,
password: null
},
touched: {
mailaddress: false,
password: false
}
})
少し複雑なように見えますがやっていることは先ほど説明した内容と一緒です。
hooksファイルに入力情報管理用のロジックを切り分けることで
コンポーネントが持つロジックの切り分けが楽にできます。
コンポーネント側の実装
次にコンポーネント側を見ていきます。
バリデーション無しのコンポーネントと内容自体はあまり変わりませんが
エラー追加と合わせてtypescriptで記述しているので少しコード量が多めです。
import React, { FC, useCallback, useEffect } from 'react'
interface Props {
onSubmit({
mailaddress: string,
password: string
}): any
}
export const OrderInputForm: FC<Props> = ({
onSubmit,
}) => {
useEffect(() => {
dispatch({
type: 'PATCH_LOGIN',
payload: {}
})
onLoad()
}, [])
const [{ inputs, errors, touched }, dispatch] = useLoginForm({
mailaddress: '',
password: ''
})
const updateMailaddress = useCallback(ev => {
dispatch({
type: 'PATCH_LOGIN',
payload: { mailaddress: ev.target.value }
})
}, [])
const updatePassword = useCallback(ev => {
dispatch({
type: 'PATCH_LOGIN',
payload: { password: ev.target.value }
})
}, [])
const submit = () => {
dispatch({
type: 'TOUCH_ALL',
payload: undefined
})
if (Object.values(errors).every(e => e === null)) {
onSubmit(inputs)
}
}
return (
<div>
// メールアドレス
<input value={inputs.mailaddress} onChange={updateMailaddress}/>
{errors.mailaddress && touched.mailaddress ? <p>{errors.mailaddress}</p> : false}
// パスワード
<input type="password" value={inputs.password} onChange={updatePassword}/>
{errors.password && touched.password ? <p>{errors.password}</p> : false}
// 決定ボタン
<button onClick={submit}>ログインする</button>
</div>
)
}
処理の流れを確認していきましょう。
- ユーザーが画面を表示する
- 全ての項目にバリデーションを走らせる。今回は各項目に「必須です」という文字列が入る。
- ユーザーがフォームに入力する
- 入力があった際は再度バリデーションが走る。問題がない項目はエラー情報にnullが入り、タッチ情報がtrueになる。
- 決定ボタンが押されたとき、エラー表示判定を全てtrueにし、エラーが表示状態になる。errorが全てnullであれば入力情報を送信する。
という形です。
これで無用な通信を制限することのできるフォームができたかと思います。
最後に
今回個人的にアハ体験だったのが、エラー表示判定に使っているtouchedの部分です。
決定ボタンを押した時のエラー表示が上手くいかず
チームリーダーに相談したところ「formikだとこの仕組みで作っているよ」と聞き今回の実装になりました。
いままでバリデーション部分の実装あまり触らなかったので良い機会でした。
間違ってる箇所などありましたら指摘いただけると幸いです!
ここまで読んでくださりありがとうございました。