OPENLOGIの細川です。2017年のアドベントカレンダーの記事でも書きましたが、弊社システムのフロント実装ではRedux Formを使用しています。
あるとき Redux Form の Github ページ を見たところ、このような記載がありました。
If you're just getting started with your application and are looking for a form solution, the general consensus of the community is that you should not put your form state in Redux.
form値をreduxの管理下に置くのは原則よろしくないということで、今後はRedux Formでないものを使わねばならないようです。
そこでRedux Formの作者が提示しているのが、React Final Formというもので、これはコア部分がFinal Formという、フレームワーク依存の無いform情報管理ライブラリが使用されてまして、そのReactラッパー版になります。もちろんFinal Formだけでも利用が可能です。
軽く使った感じではRedux Formからの移行は非常にやりやすそうではありましたが、結構以前からあるライブラリなので、他の似たものとの比較記事など眺めていたところ、React Hook Formというのが目に止まりました。
これはReact16.8.0から導入されたhooksの仕組みを利用したformライブラリということで、とても使いやすそうだったので少し触ってみることにしました。
直近の自分の課題としては、Redux Formで実装されたものと比較してどの程度楽になるのか?ということですから、2017年のアドベントカレンダーの記事と同じものを作って比較してみたいと思います。
環境
まずは create-react-app 使って簡易にreactの環境を作ります
npx create-react-app my-react-hook-form
cd my-react-hook-form
npm start
ブラウザでデフォルトの画面が立ち上がるかと思います。
この状態では react-hook-form は入ってませんのでインストールします。画面の体裁のために react-bootstrap も入れておきます。
npm install react-hook-form react-bootstrap bootstrap
今回はredux使いませんから、前回と違いreduxに関連する設定は不要になります。
ではフォーム画面作ってみましょう
基本形
まず、既に生成されている App.js
を少し書き換えてから、Form画面実装します。
import React from 'react';
import './App.css';
import showResults from "./showResults";
import MyForm from "./MyForm";
import {
Container,
Row,
Col,
} from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
function App() {
return (
<div>
<Container style={{padding: 15}}>
<Row>
<Col sm={10}>
<h3>フォーム画面</h3>
<MyForm onSubmit={showResults}/>
</Col>
</Row>
</Container>
</div>
);
}
export default App;
import React from 'react';
import useForm from 'react-hook-form'
import {
Form,
Row,
Col,
Button,
ButtonToolbar,
} from 'react-bootstrap';
const MyForm = ({onSubmit}) => {
const { register, handleSubmit, reset, formState } = useForm();
return (
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
<Form.Group as={Row} controlId={'name'}>
<Form.Label column sm={2}>{'お名前'}</Form.Label>
<Col sm={5}>
<Form.Control
name={'name'}
placeholder={'Name'}
type={'text'}
ref={register({})}
/>
</Col>
</Form.Group>
<Form.Group>
<Col smOffset={2} sm={5}>
<ButtonToolbar>
<Button variant={'primary'} type="submit" disabled={!formState.dirty || formState.isSubmitting}>登録</Button>
<Button variant={'secondary'} type="button" disabled={!formState.dirty || formState.isSubmitting} onClick={reset}>クリア</Button>
</ButtonToolbar>
</Col>
</Form.Group>
</Form>
);
};
export default MyForm
submit時のサーバーとのリクエスト、レスポンスをエミュレートするための関数を書いておきます。
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
export default (async function showResults(values) {
await sleep(500);
window.alert(`You submitted:\n\n${JSON.stringify(values, null, 2)}`);
});
Redux FormではreduxForm
関数によるformコンポーネントの修飾が必要で、且つ各入力フィールドはField
コンポーネントを経由して、描画する入力フィールド定義をしなければなりませんでした。今回は各入力コンポーネントにref={register({})}
と、useForm
フックから取得した関数呼び出しを属性に付与するだけ、です。
適当な文字列をテキストボックスに入力後、登録
ボタンを押下してみます。
入力値が反映されたダイアログが上がりました。これは
<MyForm onSubmit={showResults}/>
でonSubmit
に指定されている関数の実行結果ですが、これはMyForm
コンポーネントの
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
のhandleSubmit
を経由して、指定した関数の引数に各フィールド値が入ったオブジェクトをReact Hook Formが渡してくれてるようです。
入力チェックしてみる
では今度はvalidationしてみたいと思います。Redux FormではField
コンポーネント経由で描画する必要があるため、エラー発生時のbootstrapのスタイル指定などをする際のためにはForm.Group
単位等で切り出す必要ありましたが、今回は下記ような部分的な修正だけでも可能です。
const MyForm = ({onSubmit}) => {
const { register, handleSubmit, errors, reset, formState } = useForm();
return (
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
<Form.Group as={Row} controlId={'name'}>
<Form.Label column sm={2}>{'お名前'}</Form.Label>
<Col sm={5}>
<Form.Control
name={'name'}
placeholder={'Name'}
type={'text'}
isInvalid={errors.name}
ref={register({
required: "必須項目です!",
maxLength : {
value: 10,
message: '10文字以内で指定してください'
}
})}
/>
{
errors.name &&
<Form.Control.Feedback type="invalid">
{errors.name.message}
</Form.Control.Feedback>
}
</Col>
</Form.Group>
<Form.Group>
<Col smOffset={2} sm={5}>
<ButtonToolbar>
<Button variant={'primary'} type="submit" disabled={!formState.dirty || formState.isSubmitting}>登録</Button>
<Button variant={'secondary'} type="button" disabled={!formState.dirty || formState.isSubmitting} onClick={reset}>クリア</Button>
</ButtonToolbar>
</Col>
</Form.Group>
</Form>
);
};
修正箇所は下記のようになります。
const { register, handleSubmit, errors, reset, formState } = useForm();
...
<Form.Control
name={'name'}
placeholder={'Name'}
type={'text'}
isInvalid={errors.name}
ref={register({
required: "必須項目です!",
maxLength : {
value: 10,
message: '10文字以内で指定してください'
}
})}
/>
{
errors.name &&
<Form.Control.Feedback type="invalid">
{errors.name.message}
</Form.Control.Feedback>
}
...
useForm
フックからerrors
を取得して、errors.name
に値があればinvalidとして扱い、メッセージを表示します。register({ required: "必須項目です!" })
といった形式でvalidation定義をすることが可能になってます。
validation結果をbootstrapなスタイル指定と共に表示することも自然にできていまいました。
初期化したい
編集画面などで初期化した状態でフォーム画面表示したい場合には、useForm
のパラメータとしてdefaultValues
の名称で初期値指定ができます。
const {register, handleSubmit, errors, reset, formState} = useForm({defaultValues: {name: 'デフォルト表示'}});
...
画面リロードすると表示されました。
この時何か編集をしてクリア
ボタン押すと、初期値に戻ります。ボタン押下時の動作は
<Button variant={'secondary'} type="button" disabled={!formState.dirty || formState.isSubmitting} onClick={reset}>クリア</Button>
</Button>
で定義されてますが、このreset
関数で初期値に戻すことができるようになってます。
特定項目の値で挙動を変えたい
ある項目の入力可否が他の項目に依存していたり、特定項目の入力値で挙動を変えるために、描画時に特定の項目値を取得する必要があります。Redux Formは入力値がreduxの管理下にあるためにformSelector
という関数を使ってreduxのstoreにアクセスし、取得値をformコンポーネントに渡すという迂回した方法を取るしかなかったですが、React Hook FormではuseForm
フックから取得できるwatch
関数によって実現可能です。
const {register, handleSubmit, errors, reset, formState, watch /*<--これ*/} = useForm({defaultValues: {name: 'デフォルト表示'}});
...
あとはこれを使ってフィールドの表示制御するだけです。各項目汎用のコンポーネント定義をして少し整理します。
const Field =
({
name,
label,
type,
placeholder,
error,
register,
}) => {
return (
<Form.Group as={Row} controlId={name}>
<Form.Label column sm={2}>{label}</Form.Label>
<Col sm={5}>
<Form.Control name={name} placeholder={placeholder} type={type} isInvalid={error} ref={register}/>
{
error &&
<Form.Control.Feedback type="invalid">
{error.message}
</Form.Control.Feedback>
}
</Col>
</Form.Group>
)
};
const MyForm = ({onSubmit}) => {
const { register, handleSubmit, watch, errors, reset, formState } = useForm({defaultValues: {name: 'デフォルト表示'}});
const watchContact = watch('contact');
return (
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
<Field
name="name"
type="text"
label="お名前"
error={errors.name}
register={register({
required: "必須項目です!",
maxLength : {
value: 10,
message: '10文字以内で指定してください'
}
})}
/>
<Form.Group as={Row} controlId={'contact'}>
<Form.Label column sm={2}>連絡先</Form.Label>
<Col sm={5}>
<Form.Check
inline
name={'contact'}
type={'radio'}
id={'contact-email'}
value={'email'}
label={'メール'}
ref={register({})}
/>
<Form.Check
inline
name={'contact'}
type={'radio'}
id={'contact-phone'}
value={'phone'}
label={'電話'}
ref={register({})}
/>
</Col>
</Form.Group>
{
watchContact === 'email' &&
<Field
name="email"
type="text"
label="メールアドレス"
placeholder="example@example.com"
error={errors.email}
register={register({})}
/>
}
{
watchContact === 'phone' &&
<Field
name="phone"
type="text"
label="電話番号"
placeholder="09011112222"
error={errors.phone}
register={register({})}
/>
}
<Form.Group>
<Col smOffset={2} sm={5}>
<Button variant={'primary'} type="submit" disabled={!formState.dirty || formState.isSubmitting}>登録</Button>
{' '}
<Button variant={'secondary'} type="button" disabled={!formState.dirty || formState.isSubmitting} onClick={reset}>クリア</Button>
</Col>
</Form.Group>
</Form.Group>
</Form>
);
};
できました。
階層化されたフィールド
複数の連絡先や家族情報の入力など、動的で且つ階層化された入力欄と、それに合わせて階層化された値の取得をしたい場合があります。今回は家族の名前を追加できるようにしてみたいと思います。
同一のフォームの配列はRedux Formでは FieldArray
コンポーネントを使って実現してましたが、React Hook Formではここでも特別なことはせずに実現が可能です。
まず、react標準のフックであるuseState
を使用して、家族の入力値を管理できるようにします。更にその値を使って、家族レコードの追加削除を表現する実装を追加します。
const MyForm = ({onSubmit}) => {
const [familyIndexes, setFamilyIndexes] = useState([]);
const formHooks = useForm({defaultValues: {name: 'デフォルト表示'}});
const { register, handleSubmit, watch, errors, reset, formState } = formHooks;
const watchContact = watch('contact');
const addFamilies = () => {
setFamilyIndexes(current => [...current, current.length > 0 ? Math.max(...current) + 1 : 0]);
};
const removeFamilies = index => () => {
setFamilyIndexes(current => [...current.filter(i => i !== index)]);
};
...
<Families familyIndexes={familyIndexes} addFamilies={addFamilies} removeFamilies={removeFamilies} formHooks={formHooks}/>
次に配列形式でformフィールドを描画するコンポーネントを追加します。先ほど追加した familyIndexes
の値を使ってname={'families[1].name'}
といった配列形式のフィールド名を指定することによって、form入力値を配列形式で管理することが可能になります。
const FamilyFields = ({familyIndex, index, removeFamilies, formHooks }) => {
const fieldPrefix = `families[${familyIndex}]`;
return (
<ListGroup.Item>
<Form.Row>
<Button variant={'danger'} type="button" style={{marginRight: '10px'}} onClick={removeFamilies(familyIndex)}>削除</Button>
<Form.Label column sm={2}>家族 {index + 1}</Form.Label>
<Col sm={4}>
<Form.Control
name={`${fieldPrefix}.name`}
type="text"
ref={formHooks.register}/>
</Col>
</Form.Row>
</ListGroup.Item>
);
};
const Families = ({ familyIndexes, addFamilies, removeFamilies, formHooks }) => (
<Form.Group as={Row}>
<Form.Label column sm={2}>家族</Form.Label>
<Col sm={8}>
<ButtonToolbar>
<Button variant={'secondary'} type="button" onClick={addFamilies}>追加</Button>
</ButtonToolbar>
<ListGroup>
{
familyIndexes.map((familyIndex, index) =>
<FamilyFields key={familyIndex} familyIndex={familyIndex} index={index} removeFamilies={removeFamilies} formHooks={formHooks} />
)
}
</ListGroup>
</Col>
</Form.Group>
);
こんな感じの入力で、
表示が部分的ですが、値が階層化されて取れてます。
非同期でサーバーでのvalidationがしたい
Redux Formでは非同期validationを特定フィールドに指定する仕組みがありましたが、React Hook Formではどうでしょうか。
ここでは電話番号を非同期チェックしてみたいと思います。まず、サーバーサイドvalidationをエミュレートする実装を用意します。
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
export default (async function asyncValidate(phone) {
await sleep(1000);
if (!!phone && !phone.match(/^(0[5-9]0[0-9]{8}|0[1-9][1-9][0-9]{7})$/)) {
return false;
}
return true;
});
これを単にregister
関数のvalidation定義で非同期呼び出しするだけです。
<Field
name="phone"
type="text"
label="電話番号"
placeholder="09011112222"
error={errors.phone}
register={register({
validate: async value => await asyncValidate(value) || '不正な電話番号です'
})}
/>
特別なこと知らずとも直感的な実装ができるのはいいですね。
複数画面に分割されたフォーム
モーダルや画面遷移などで表示された別フォームでの入力を、同じフォームの値として保持したい場合にどうするか、というケースになります。Redux Formは値がreduxのstore管理になっていたため、コンポーネントのumount時にform値消去をしない設定をするだけで複数画面にまたがるform値維持が可能でした。React Hook Formではどうでしょうか。
まず、前回と同様に家族の情報を入力するフォームを別画面にしてみます。src/App.js
でstateフックを使ってページ番号を取得・設定できるようにし、表示画面を制御します。更に、submitまでの全フィールド値を管理する値もstateフックで作成し、そこに各ページformのsubmit時に値をマージする形にします。
import React, { useState } from 'react';
import './App.css';
import showResults from "./showResults";
import MyForm from "./MyForm";
import MyFamilyForm from "./MyFamilyForm";
import {
Container,
Row,
Col,
} from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
function App() {
const [page, setPage] = useState(1);
const [data, setData] = useState({});
const nextPage = (values) => {
setPage(page + 1);
setData(prevData => ({
...prevData,
...values,
}));
};
return (
<div>
<Container style={{padding: 15}}>
<Row>
<Col sm={10}>
<h3>フォーム画面</h3>
{
page === 1 &&
<MyForm onSubmit={nextPage} />
}
{
page === 2 &&
<MyFamilyForm onSubmit={(values) => showResults({
...data,
...values,
})}
/>
}
</Col>
</Row>
</Container>
</div>
);
}
export default App;
元のMyForm.js
から家族情報を切り出します
import React from 'react';
import useForm from 'react-hook-form'
import {
Form,
Row,
Col,
Button,
ButtonToolbar,
} from 'react-bootstrap';
import asyncValidate from "./asyncValidate";
const Field =
({
name,
label,
type,
placeholder,
error,
register,
}) => {
return (
<Form.Group as={Row} controlId={name}>
<Form.Label column sm={2}>{label}</Form.Label>
<Col sm={5}>
<Form.Control name={name} placeholder={placeholder} type={type} isInvalid={error} ref={register}/>
{
error &&
<Form.Control.Feedback type="invalid">
{error.message}
</Form.Control.Feedback>
}
</Col>
</Form.Group>
)
};
const MyForm = ({onSubmit}) => {
const { register, handleSubmit, watch, errors, reset, formState } = useForm({defaultValues: {name: 'デフォルト表示'}});
const watchContact = watch('contact');
return (
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
<Field
name={'name'}
type="text"
label="お名前"
error={errors.name}
register={register({})}
/>
<Form.Group as={Row} controlId={'contact'}>
<Form.Label column sm={2}>連絡先</Form.Label>
<Col sm={5}>
<Form.Check
inline
name={'contact'}
type={'radio'}
id={'contact-email'}
value={'email'}
label={'メール'}
ref={register({})}
/>
<Form.Check
inline
name={'contact'}
type={'radio'}
id={'contact-phone'}
value={'phone'}
label={'電話'}
ref={register({})}
/>
</Col>
</Form.Group>
{
watchContact === 'email' &&
<Field
name="email"
type="text"
label="メールアドレス"
placeholder="example@example.com"
error={errors.email}
register={register({})}
/>
}
{
watchContact === 'phone' &&
<Field
name="phone"
type="text"
label="電話番号"
placeholder="09011112222"
error={errors.phone}
register={register({
validate: async value => await asyncValidate(value) || '不正な電話番号です'
})}
/>
}
<Form.Group>
<Col smOffset={2} sm={5}>
<Button variant={'primary'} type="submit" disabled={!formState.dirty || formState.isSubmitting}>登録</Button>
{' '}
<Button variant={'secondary'} type="button" disabled={!formState.dirty || formState.isSubmitting} onClick={reset}>クリア</Button>
</Col>
</Form.Group>
</Form.Group>
</Form.Group>
</Form>
);
};
export default MyForm
import React, { useState } from 'react';
import useForm, { FormContext, useFormContext } from 'react-hook-form'
import {
Form,
Row,
Col,
Button,
ButtonToolbar,
ListGroup,
} from 'react-bootstrap';
const FamilyFields = ({familyIndex, index, removeFamilies }) => {
const { register } = useFormContext();
const fieldPrefix = `families[${familyIndex}]`;
return (
<ListGroup.Item>
<Form.Row>
<Button variant={'danger'} type="button" style={{marginRight: '10px'}} onClick={removeFamilies(familyIndex)}>削除</Button>
<Form.Label column sm={2}>家族 {index + 1}</Form.Label>
<Col sm={4}>
<Form.Control
name={`${fieldPrefix}.name`}
type="text"
ref={register}/>
</Col>
</Form.Row>
</ListGroup.Item>
);
};
const Families = ({ familyIndexes, addFamilies, removeFamilies }) => (
<Form.Group as={Row}>
<Form.Label column sm={2}>家族</Form.Label>
<Col sm={8}>
<ButtonToolbar>
<Button variant={'secondary'} type="button" onClick={addFamilies}>追加</Button>
</ButtonToolbar>
<ListGroup>
{
familyIndexes.map((familyIndex, index) =>
<FamilyFields key={familyIndex} familyIndex={familyIndex} index={index} removeFamilies={removeFamilies} />
)
}
</ListGroup>
</Col>
</Form.Group>
);
const MyFamilyForm = ({onSubmit}) => {
const [familyIndexes, setFamilyIndexes] = useState([]);
const formMethods = useForm({});
const { handleSubmit, formState } = formMethods;
const addFamilies = () => {
setFamilyIndexes(current => [...current, current.length > 0 ? Math.max(...current) + 1 : 0]);
};
const removeFamilies = index => () => {
setFamilyIndexes(current => [...current.filter(i => i !== index)]);
};
return (
<FormContext {...formMethods} >
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
<Families familyIndexes={familyIndexes} addFamilies={addFamilies} removeFamilies={removeFamilies} />
<Form.Group>
<Col smOffset={2} sm={5}>
<Button variant={'primary'} type="submit" disabled={!formState.dirty || formState.isSubmitting}>登録</Button>
</Col>
</Form.Group>
</Form.Group>
</Form>
</FormContext>
);
};
export default MyFamilyForm
MyFamilyForm.js
ではFormContext
とuseFormContext
が新しく登場しますが、名前の通り、これを使うことでネストしたformコンポーネントにフックから取得したメソッド類を明示的にprops経由でバケツリレーしないでよくなります。
React Hook Formのformフックは表示されているformコンポーネントの値のみが有効になってしまうので、App.js
でuseForm({})
してその値を各ページコンポーネントに渡す形ではダメでした。ですので、上の例のような何かしらform値全体を表現する値にページごとの値をマージしていく必要があるようです。
ここは少し不便なようにも思われましたが、Redux Formのようにライブラリならではの特殊な設定値を知る必要が無い、という点はやはりよいところかと思います。また、上記のような管理を汎用的にやりたい場合は、状態管理のライブラリを別途利用することもよい選択かと思われます。ちなみにReact Hook Formのドキュメントではlittle-state-machineを使用した例が記載されてます。redux使っても良いと書かれてますが、冒頭で述べた通り、form値の類をreduxのstore管理するのはよろしくないとのことで、今後は避けたほうがよいのでしょう。
では動かしてみます。
画面遷移して登録ボタン押すと
無事複数画面をまたいだ値を submit することができたようです。
感想
form実装でよくあるパターンで軽く実装してみましたが、思ったよりも使いやすかったというのが感想です。とにかくライブラリ独自方言の設定や記述方法を学習する必要があまり無い、というのが大きいですし、hooks使えば当然なのでしょうが、コード量は確実に減ります。しかも高階にするなどの方法と違って見通しが非常によいところが好感持てます。
また、ここでは記載しませんでしたが、React Hook Formは他の同様のライブラリと比較してパフォーマンス的にも優位にあるそうです。更に、より複雑なvalidationについては、Yupのschemaとの連携なども可能になっていたり、リッチなUIコンポーネントとの連携も可能になってます。
form配列や複数画面のformを扱うところはもう少しライブラリの機能があるといいな、とは思いましたが、通常の実装で必要な一通りのことは十分サポートしてくれてると思います。まだ新しめのライブラリですから、今後の拡張にも期待できるのではないでしょうか。
参考
React Hook Form - Simple React forms validation
React form validation library built under 5kB - Bill - Medium
React Hook Form vs. Formik: A technical and performance comparison - LogRocket Blog