⌨️

Reactのフォームライブラリの「入力していない」状態の扱いについて

2021/08/04に公開1

Reactでフォームを扱う時、シンプルであればライブラリを使わなくても書けるものの、なんだかんだでフォームライブラリを選ぶことは多いのではと思います。各ライブラリのAPIは、最適化の手段や設計思想が違うことで似ているようで似ていないのが実体です。今回は中でも「入力していない」状態の扱いが各ライブラリでかなり異なることがわかったのでまとめてみたいと思います。

比較するライブラリはよく使う3つ

  • react-hook-form
  • react-final-form
  • formik

実装は各公式サンプルの最もシンプルなものをベースにしています。

比較サンプル

シンプルな実装で比較するとわかりやすいので全て同じ仕様とします

#フォーム
①フォームは2つのフィールドを持つ
* FieldA(必須)
* FieldB(任意)
②submitボタンをクリックでsubmitされ、エラーが無い場合はconsole.logにvalueを表示する
③フォームの値は常に<pre>タグ内に表示する

#カウンター
フォーム外での再レンダリングの影響調査用

比較パターン

「入力していない」といっても色々な状態があります。今回は下記の5パターンをそれぞれ行い、フォームの値がどうなるかを見てみます。

  • 初期レンダリング時
  • 再レンダリング時(form外のstate変更。今回はcounterを+1)
  • 1フィールドのみ入力した時(FieldAのみ入力)
  • 入力後空にした時
  • 1フィールドのみ入力してsubmitした時

結論

結構違う。

react-hook-form formik react-final-form
初期レンダリング {} {} {}
再レンダリング(counter +1) {"filedA":"","fieldB":""} {} {}
FieldAのみ入力 {"filedA":"あいうえお","fieldB":""} {"fieldA":"あいうえお"} {"fieldA":"あいうえお"}
入力後空にする {"filedA":"","fieldB":""} {"fieldA":""} {}
FieldAのみ入力してsubmit {filedA: "あいうえお", fieldB: ""} {"fieldA":"あいうえお", "fieldB": undefined} {"fieldA":"あいうえお"}

※実装にも影響を受けるので必ずしもこうなるとは限りません(formikはinitialValuesが必須で、今回はundefinedを指定している等)。

思ったこと

  • react-hook-formは入力していない状態は基本empty stringとする方針。これはJS的なstateよりもhtml的な状態を重視しているためだが、初期レンダリングとそれ以降の値に一貫性がない。
  • react-final-formはempty stringを扱わない方針。htmlよりもJS的なstateを正としているため一貫性は高い。
  • formikは双方の中間的な立ち位置に見える。一度onChangeが走ればhtmlを正とする挙動
  • こうした挙動を念頭にvalidationやsubmit時のロジックを書く必要がありそう

codesandbox

react-hook-form

function App() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors }
  } = useForm();

  const onSubmit = (data) => {
    console.log("result", data);
  };

  const [count, setCount] = useState(0);

  return (
    <section>
      {/* Form */}
      <div>
        <h2>Form</h2>
        <form onSubmit={handleSubmit(onSubmit)}>
          <label>FieldA</label>
          <input {...register("filedA", { required: true })} />
          {errors.fieldA && <p>required</p>}
          <label>FieldB</label>
          <input {...register("fieldB")} />
          <input type="submit" />
          <pre>{JSON.stringify(watch())}</pre>
        </form>
      </div>
      <hr />
      {/* Counter */}
      <div>
        <h2>Counter</h2>
        <button
          onClick={() => {
            setCount((prev) => prev + 1);
          }}
        >
          +
        </button>
        <pre>{count}</pre>
      </div>
    </section>
  );
}

formik

function App() {
  const [count, setCount] = useState(0);

  const onSubmit = (data) => {
    console.log("result", data);
  };

  return (
    <section>
      {/* Form */}
      <div>
        <h2>Form</h2>
        <Formik
          initialValues={{
            fieldA: undefined,
            fieldB: undefined
          }}
          onSubmit={onSubmit}
        >
          {(props) => (
            <Form>
              <label>FieldA</label>
              <Field
                name="fieldA"
                validate={(value) => (!value ? "required" : undefined)}
              />
              {props.errors.fieldA && <p>{props.errors.fieldA}</p>}
              <label>FieldB</label>
              <Field name="fieldB" />
              <button type="submit">Submit</button>
              <pre>{JSON.stringify(props.values)}</pre>
            </Form>
          )}
        </Formik>
      </div>
      {/* Counter */}
      <hr />
      <div>
        <h2>Counter</h2>
        <button
          onClick={() => {
            setCount((prev) => prev + 1);
          }}
        >
          +
        </button>
        <pre>{count}</pre>
      </div>
    </section>
  );
}

react-final-form

function App() {
  const [count, setCount] = useState(0);

  const onSubmit = (data) => {
    console.log("result", data);
  };

  return (
    <section>
      {/* Form */}
      <div>
        <h2>Form</h2>
        <Form
          onSubmit={onSubmit}
          render={({ handleSubmit, submitting, pristine, values, errors }) => (
            <form onSubmit={handleSubmit}>
              <label>FieldA</label>
              <Field
                name="fieldA"
                component="input"
                validate={(value) => (!value ? "required" : undefined)}
              />
              {errors.fieldA && <p>{errors.fieldA}</p>}
              <label>FieldB</label>
              <Field name="fieldB" component="input" />
              <input type="submit" />
              <pre>{JSON.stringify(values)}</pre>
            </form>
          )}
        />
      </div>
      {/* Counter */}
      <hr />
      <div>
        <h2>Counter</h2>
        <button
          onClick={() => {
            setCount((prev) => prev + 1);
          }}
        >
          +
        </button>
        <pre>{count}</pre>
      </div>
    </section>
  );
}

Discussion

nozominozomi

react-hook-formは入力していない状態は基本empty stringとする方針。これはJS的なstateよりもhtml的な状態を重視しているためだが、初期レンダリングとそれ以降の値に一貫性がない。

graphqlを使っているとこの使用が不便で配列や、number型でもvalueが空だったときに空文字列が送られてしまっているのですが、みなさんはどうやって対応しているのでしょうか。。。

自分は
https://github.com/react-hook-form/react-hook-form/issues/656
こちらを見て

import { pickBy } from 'lodash';

// Strip out all values with empty strings
const sanitizedValues = pickBy(values, value => value.length > 0);

handleSubmitごとにこの関数を書いて対応しているのですが、handleSubmitの度に書くのがのめんどくさいです。。