Reactのカスタムhookで作るフォーム
何番煎じかわからないが実装してみたから残しておく。
やりたいこと
- Reactのカスタムhookとして実装する
- フォームのバリデーションをする
- フォームをなるべく共通化する
流れとしてはReact hooksを自分で定義してフォームをそれっぽく扱えるようにし、そのhookを使ってログインフォームを実装する。
バージョンとか
package.jsonからTypeScript, Reactあたりを抜粋するとこんな感じ
{ "dependencies": { "@types/react": "^16.9.1", "@types/react-dom": "^16.8.5", "@types/react-router-dom": "^5.1.2", "react": "^16.9.0", "react-dom": "^16.9.0", "react-router-dom": "^5.1.2", "typescript": "^3.7.2", ... }
カスタムhookの実装
まずカスタムhookとしてuseForm
を実装する。
フォームとしてスタイル以外のところ、Stateの管理やバリデーションなどを引き受けるコンポーネントとなる。
面倒なのでコード全体にコメントで説明。
import * as React from 'react'; // バリデーションを実行するためのI/F export interface Validator<State extends {}> { runAll(state: State): Errors; } // バリデーションエラーをMapで保持する export type Errors = Map<string, string>; // カスタムhook // - submit時に実行する関数 // - 初期state // - バリデーション関数 export const useForm = <State extends {}>(onSubmit, initial: State, validator: Validator<State>) => { // 入力状態 const [state, setState] = React.useState<State>(initial); // バリデーションエラー const [errors, setErrors] = React.useState<Errors>(new Map()); // submitボタンが有効かどうか const [submitEnabled, submitEnable] = React.useState<boolean>(false); const isValid: () => boolean = React.useCallback(() => { // バリデーションを実行 const _errors = Array.from(validator.runAll(state)).reduce<Map<string, string>>( (map, [key, error]) => { if (error) { return map.set(key, error); } else { return map; } }, new Map(), // reduceの初期値 ); setErrors(_errors); // stateの更新 return _errors.size == 0; }, [state]); // submitボタンが押されたときの挙動 const handleSubmit = React.useCallback( event => { if (event) event.preventDefault(); if (submitEnabled && isValid()) { // validな入力ならsubmit可 onSubmit(state); } else { console.log(`submit is disabled. error = ${errors}`); } }, [state], ); // 入力に変更があったときの挙動 const handleChange = React.useCallback( event => { // 入力された値をeventから取り出す const name: string = event.target.name; const value: string = event.target.value; if (!submitEnabled && value.length > 0) { // 何かしらフォームに入力がなされているとsubmitボタンを押せるようにする submitEnable(true); } // stateを更新する setState(prevState => { const newState = { ...prevState, [name]: value }; return newState; }); }, [state], ); // useFormの提供するI/F return { state, // 入力された値 errors, // バリデーションエラー disabled: !submitEnabled, // submit可能かどうか handleChange, // 入力が変更された場合のcallback handleSubmit, // submitされた場合のcallback }; };
一部のhooksでは引数として依存する値を配列で渡すことができ、大体はstateが変わらなければ結果は同じなので[state]
を渡していたりする。
どうやって使うか
たとえばLoginフォーム。 こちらもコードで説明。
import * as React from 'react'; import {Redirect} from 'react-router-dom'; import {Errors, useForm, Validator} from './useForm'; // Loginフォームの状態 type LoginState = { readonly email: string; readonly password: string; }; // useFormで要求されるフォームのバリデーション関数たち const validator: Validator<LoginState> = new (class implements Validator<LoginState> { emailValidator(email: string): string | null { if (email.length == 0) { return 'Email is empty.'; } else if (!email.match(/.+@.+\..+/)) { return 'Email is not valid format'; } } passwordValidator(password: string): string | null { if (password.length == 0) { return 'Password is empty.'; } else if (password.length < 8) { return 'Password min length is 8'; } else if (password.length > 100) { return 'Password max length is 100'; } } runAll(state: LoginState): Map<string, string> { return new Map([ ['email', this.emailValidator(state.email)], ['password', this.passwordValidator(state.password)], ]); } })(); const Login = () => { // ログイン状態の保持 const [loggedIn, setLoggedIn] = React.useState(false); if (loggedIn) { // すでにloginしていたらリダイレクトする return <Redirect to={"/"} />; } // submit時のログイン処理 const onSubmit = values => { if (values.email && values.password) { ApiClient // 外部APIを叩いている想定 .login(values.email, values.password) .then(response => { setLoggedIn(true); }) .catch(err => { console.log(`Failed to login. message = ${err}`); }); } else { console.log('Invalid inputs'); } }; // useFormを使ったフォームの状態管理 const { state, errors, disabled, handleChange, handleSubmit } = useForm<LoginState>( onSubmit, // submit時の処理 { // 初期値 email: '', password: '', }, validator, // バリデーション関数たち ); // フォームのJSX(スタイル) return ( <form onSubmit={handleSubmit}> <Label>Email: <Input key={'email'} name={'email'} type={'email'} value={state.email} onChange={handleChange} /> <div>{errors.get('email')}</div> </Label> <Label>Password: <Input key={'password'} name={'password'} type={'password'} value={state.password} onChange={handleChange} /> <div>{errors.get('password')}</div> </Label> <button type="submit" disabled={disabled}>送信</button> </form> ); }; export default Login;
これでログインフォームは動く、はず。 とはいえ認証した結果をLoginコンポーネント内だけで持っているのは流石に実用出来ないが。
さっくりまとめると、
useForm
の責務- formの状態管理
- submit時のバリデーションとエラーの保持
useForm
を使う側の責務
という感じ。 formに入力されている値の保持とかバリデーション実行とかはよくあることなので共通化してあると考える頻度が減って良い。