127
117

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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画面実装します。

src/App.js
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;
src/MyForm.js
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時のサーバーとのリクエスト、レスポンスをエミュレートするための関数を書いておきます。

src/showResults.js
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フックから取得した関数呼び出しを属性に付与するだけ、です。

適当な文字列をテキストボックスに入力後、登録ボタンを押下してみます。

Screenshot from 2019-12-05 09-02-47.png

入力値が反映されたダイアログが上がりました。これは

src/App.js
<MyForm onSubmit={showResults}/>

onSubmitに指定されている関数の実行結果ですが、これはMyFormコンポーネントの

src/MyForm.js
<Form noValidate onSubmit={handleSubmit(onSubmit)}>

handleSubmitを経由して、指定した関数の引数に各フィールド値が入ったオブジェクトをReact Hook Formが渡してくれてるようです。

入力チェックしてみる

では今度はvalidationしてみたいと思います。Redux FormではFieldコンポーネント経由で描画する必要があるため、エラー発生時のbootstrapのスタイル指定などをする際のためにはForm.Group単位等で切り出す必要ありましたが、今回は下記ような部分的な修正だけでも可能です。

src/MyForm.js
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なスタイル指定と共に表示することも自然にできていまいました。

Screenshot from 2019-12-08 16-26-45.png

初期化したい

編集画面などで初期化した状態でフォーム画面表示したい場合には、useFormのパラメータとしてdefaultValuesの名称で初期値指定ができます。

src/MyForm.js
  const {register, handleSubmit, errors, reset, formState} = useForm({defaultValues: {name: 'デフォルト表示'}});
    ...

画面リロードすると表示されました。

Screenshot from 2019-12-05 15-09-41.png

この時何か編集をしてクリアボタン押すと、初期値に戻ります。ボタン押下時の動作は

src/MyForm.js
<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関数によって実現可能です。

src/MyForm.js
  const {register, handleSubmit, errors, reset, formState, watch /*<--これ*/} = useForm({defaultValues: {name: 'デフォルト表示'}});
    ...

あとはこれを使ってフィールドの表示制御するだけです。各項目汎用のコンポーネント定義をして少し整理します。

src/MyForm.js
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>
  );
};

Screenshot from 2019-12-08 16-28-47.png

できました。

階層化されたフィールド

複数の連絡先や家族情報の入力など、動的で且つ階層化された入力欄と、それに合わせて階層化された値の取得をしたい場合があります。今回は家族の名前を追加できるようにしてみたいと思います。

同一のフォームの配列はRedux Formでは FieldArray コンポーネントを使って実現してましたが、React Hook Formではここでも特別なことはせずに実現が可能です。

まず、react標準のフックであるuseStateを使用して、家族の入力値を管理できるようにします。更にその値を使って、家族レコードの追加削除を表現する実装を追加します。

src/MyForm.js
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入力値を配列形式で管理することが可能になります。

src/MyForm.js
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>
);

こんな感じの入力で、

Screenshot from 2019-12-12 16-28-34.png

表示が部分的ですが、値が階層化されて取れてます。

Screenshot from 2019-12-05 20-46-30.png

非同期でサーバーでのvalidationがしたい

Redux Formでは非同期validationを特定フィールドに指定する仕組みがありましたが、React Hook Formではどうでしょうか。

ここでは電話番号を非同期チェックしてみたいと思います。まず、サーバーサイドvalidationをエミュレートする実装を用意します。

src/asyncValidate.js
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) || '不正な電話番号です'
          })}
        />

特別なこと知らずとも直感的な実装ができるのはいいですね。

Screenshot from 2019-12-08 16-35-15.png

複数画面に分割されたフォーム

モーダルや画面遷移などで表示された別フォームでの入力を、同じフォームの値として保持したい場合にどうするか、というケースになります。Redux Formは値がreduxのstore管理になっていたため、コンポーネントのumount時にform値消去をしない設定をするだけで複数画面にまたがるform値維持が可能でした。React Hook Formではどうでしょうか。

まず、前回と同様に家族の情報を入力するフォームを別画面にしてみます。src/App.jsでstateフックを使ってページ番号を取得・設定できるようにし、表示画面を制御します。更に、submitまでの全フィールド値を管理する値もstateフックで作成し、そこに各ページformのsubmit時に値をマージする形にします。

src/App.js
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から家族情報を切り出します

src/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
src/MyFamilyForm.js
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ではFormContextuseFormContextが新しく登場しますが、名前の通り、これを使うことでネストしたformコンポーネントにフックから取得したメソッド類を明示的にprops経由でバケツリレーしないでよくなります。

React Hook Formのformフックは表示されているformコンポーネントの値のみが有効になってしまうので、App.jsuseForm({})してその値を各ページコンポーネントに渡す形ではダメでした。ですので、上の例のような何かしらform値全体を表現する値にページごとの値をマージしていく必要があるようです。

ここは少し不便なようにも思われましたが、Redux Formのようにライブラリならではの特殊な設定値を知る必要が無い、という点はやはりよいところかと思います。また、上記のような管理を汎用的にやりたい場合は、状態管理のライブラリを別途利用することもよい選択かと思われます。ちなみにReact Hook Formのドキュメントではlittle-state-machineを使用した例が記載されてます。redux使っても良いと書かれてますが、冒頭で述べた通り、form値の類をreduxのstore管理するのはよろしくないとのことで、今後は避けたほうがよいのでしょう。

では動かしてみます。

Screenshot from 2019-12-08 16-37-16.png

画面遷移して登録ボタン押すと

Screenshot from 2019-12-12 16-42-05.png

無事複数画面をまたいだ値を 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

127
117
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
127
117

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?