はじめに
Reactでフォーム作るのってどうするのがいいんだっけと思い調べるとFormikに関する情報がたくさん出てきたので触ってみました。
↓Formik知らない方はこちら。これちゃんと見るだけで雰囲気は完全に把握できます!!
参考:https://www.youtube.com/watch?v=yNiJkjEwmpw&t=2005s
たしかに便利なんですが、SPAのフォームって結局なんだかんだめっっっっっっちゃいっぱい書かなきゃいけなくないですか。。Angularやってたときもいまいち美しい方法を見いだせず。。。
せめてViewとロジックをきれいに分離したいなぁと思い、汎用性のありそうなコンポーネント作ってみました。
カスタムコンポーネントを使うとこうなる
ユーザーの登録・編集フォームという想定。
まずはViewの部分から。
import { Form } from 'formik';
import React from 'react';
// 自分で作ったやつ
import { Check, Input, Radio, Select } from '../shared/Form';
const UserFormTemplate = ({ formItems, isSubmitting }) => (
  <Form>
    <div>
      <Input type="text" name="name" placeholder="氏名" />
    </div>
    <div>
      <Input type="email" name="mail" placeholder="メールアドレス" />
    </div>
    <div>
      <Radio name="gender" data={formItems.gender} />
    </div>
    <div>
      <Select name="blood_type" data={formItems.blood_type} blank />
    </div>
    <div>
      <Check name="hobby_ids" data={formItems.hobby} />
    </div>
    <div>
      <Input type="password" name="password" placeholder="パスワード" />
    </div>
    <div>
      <Input type="password" name="password_confirmation" placeholder="パスワード(確認用)" />
    </div>
    <button type="submit" disabled={isSubmitting}>
      登録
    </button>
  </Form>
);
おおお、とても見通しの良いすっきりした形に切り出せました!!
後述のロジック部と組み合わせるとこれだけできちんとエラーメッセージも表示できるんです〜。
今回はやっていないのですが、項目名もカスタムコンポーネントの中にいれることもできそうです。
このフォームでは項目を、氏名、メールアドレス、性別、血液型、趣味(複数選択可)としました。
なお、formItemsにはチェックボックス、ラジオボタン、セレクトボックスなどの選択肢に必要な情報が配列で入っています。
例:formItems.blood_type (血液型のセレクトボックス)
[
  {label: "A", value: 1},
  {label: "B", value: 2},
  {label: "O", value: 3},
  {label: "AB", value: 4},
]
この配列の情報をもとにカスタムコンポーネント内で選択肢を作っています。
続いてFormikロジック部はこちら。こちらはとくにひねりはなく普通の感じです。
Formik見慣れない方のために念の為コメントで補足情報いれます!
import { Form, withFormik } from 'formik';
import React from 'react';
import Yup from '../config/yup.custom';
import {
  Check, Input, Radio, Select,
} from '../shared/Form';
・
・ // このへんにさっきのUserFormTemplateがいる
・
const UserForm = withFormik({
  // フォームの初期値の設定。
  // 新規作成フォームのときは初期値は空文字、編集フォームのときは該当ユーザーの値を入れる
  // ただしパスワードは常に初期値は空文字(そもそも暗号化して保存しているはずなので初期値を入れられない)
  mapPropsToValues: ({ user }) => {
    const {
      name, mail, gender, blood_type, hobby_ids,
    } = user || {};
    return {
      name: name || '',
      mail: mail || '',
      gender: gender || '',
      blood_type: blood_type || '',
      hobby_ids: hobby_ids || [],
      password: '',
      password_confirmation: '',
    };
  },
  // バリデーション
  // 必須項目とか最低何文字とか設定
  // パスワードに関しては新規のときのみ必須にして、更新時は入力なしでもOK(パスワードを変更しないのも可)としたくて、
  // 謎に即時関数使ってますがそのあたりはお気になさらず。。
  // サーバーサイドのRailsに合わせてスネークケース使ってるのも許してください><
  validationSchema: ({ isNewUser }) => (
    Yup.object().shape({
      name: Yup.string().required(),
      mail: Yup.string().email().required(),
      gender: Yup.number().required(),
      blood_type: Yup.number().required(),
      hobby_ids: Yup.array(),
      password: ((v) => (isNewUser ? v.required() : v))(Yup.string().min(8)),
      password_confirmation: ((v) => (isNewUser ? v.required() : v))(
        Yup.string().min(8).oneOf([Yup.ref('password')], 'パスワードが一致しません'),// refで他の欄を参照できます!
      ),
    })),
  // フォームサブミット時の処理
  handleSubmit: (values, // フォームで入力した値が入っている
    {
      props: { user, registerUser }, // 編集ユーザーの情報(初期値)、および、情報登録実行の関数
      setErrors, // フォームに手動でエラーメッセージを設定するための関数(Formik由来の関数)
      setSubmitting, // フォームのサブミットボタンの活性を切り替えるための関数(Formik由来の関数)
    }) => {
    registerUser({ ...user, ...values }) // 初期値を入力値で上書きして非同期通信で登録
      .catch((e) => {
        console.error(e, e.message, e.response, e.response.data); // 失敗したらとりあえずロギング
        // 422はUnprocessable Entity(サーバーサイドのバリデーションエラーが起きたら422を返している)
        // メールアドレスのユニーク制約など、サーバーサイドでしか判定できない項目に関してはここで拾う
        if (e.response.status === 422) {
          // フォームにエラーを登録できるように、
          // サーバーサイドではエラーメッセージをFormikにあった形にうまいこと成形してレスポンスを返している。
          // Railsで
          // render status: 422,  json: { error: user.errors.to_hash(true) }
          // ってやってます
          // errors.to_hash(true)で項目ごとのfull_messages取れるの初めて知りました〜
          // ここで登録したメッセージは配列なので後述のカスタムコンポーネントの<ErrorMsg />が活躍する
          setErrors(e.response.data.error);
          // サブミットボタンを復活させる
          // (Formikの機能により押下時に自動でisSubmittingがtrueにされてるっぽいのでfalseに戻す)
          setSubmitting(false);
        }
      });
  },
  // 初期値が変わった際にフォームに新しい初期値を教えるか。教えたいのでtrue。
  // Hooksやライフサイクルメソッドと、初期値を渡して描画させるタイミングの兼ね合いをうまく調節できるならデフォルトのfalseのままでもいいかも。
  enableReinitialize: true,
})(UserFormTemplate);
export default UserForm;
いや〜普通にFormik使ってるだけなんですがロジック部はやっぱいっぱい書かなきゃなんですよね〜。。
カスタムコンポーネント
ここがこの記事のメインです!
styled-components使ってます。
import { Field, FieldArray } from 'formik';
import React from 'react';
import styled, { css } from 'styled-components';
import { Color } from '../config/StyleConst';
// エラーなら赤枠で囲う(テキストボックス)
export const InputStyled = styled.input`
  ${({ error }) => (error && css`
      border: 1px solid ${Color.Red};
    `
  )}
`;
// エラーなら赤枠で囲う(ラジオボタン)
export const RadioStyled = styled.input`
  ${({ error }) => (error && css`
      outline: 1px solid ${Color.Red};
    `
  )}
`;
// エラーなら赤枠で囲う(セレクトボックス)
export const SelectStyled = styled.select`
  ${({ error }) => (error && css`
      border: 1px solid ${Color.Red};
    `
  )}
`;
// 赤いエラーメッセージ
export const Error = styled.p`
  color: ${Color.Red};
`;
// 【①】エラーメッセージの表示
// Formikはどうやら一度に表示できるメッセージは一つだけらしい
// (ちなみに単一エラーメッセージ表示にはFormik由来の<ErrorMessage />というコンポーネントがある)
// しかし、サーバーサイドのバリデーションメッセージ(複数もありうる)も表示したかったため、
// msgsが文字列の場合(Formikのバリデーションメッセージ)と、配列の場合(サーバーサイドのバリデーションメッセージ)に対応
// mapのkeyにindexを入れるなってLintに怒られたのでやけくそでmsgをいれた。。
// こういうときってホントは何を入れればいいんだろう。。。
export const ErrorMsg = ({ children: msgs }) => (
  <>
    {Array.isArray(msgs)
      ? msgs.map((msg) => <Error key={msg}>{msg}</Error>)
      : <Error>{msgs}</Error>}
  </>
);
// 【②】テキストボックス
// ほんとはInputStyledに{...field}ってしたかったけどLintにおこられた。。。
// onChangeとonBlurを入れてあげないとバリデーションがうまく動かないはず。後述の③〜⑤も同様。
export const Input = ({ name, type, placeholder }) => (
  <Field name={name}>
    {({ field, meta }) => {
      const {
        value, onChange, onBlur,
      } = field;
      const error = meta.touched && meta.error;
      return (
        <>
          <InputStyled
            error={error}
            type={type}
            name={name}
            value={value}
            onChange={onChange}
            onBlur={onBlur}
            placeholder={placeholder}
          />
          // エラーがあればエラーメッセージ表示
          {error && <ErrorMsg>{meta.error}</ErrorMsg>}
        </>
      );
    }}
  </Field>
);
// 【③】セレクトボックス
export const Select = ({
  name, data, blank, valueIsString,
} = { valueIsString: false }) => (
  <Field name={name}>
    {({ field, form: { setFieldValue }, meta }) => {
      const {
        value: fieldValue, onChange, onBlur,
      } = field;
      const error = meta.touched && meta.error;
      return (
        <>
          <SelectStyled
            error={error}
            name={name}
            value={fieldValue}
            onChange={(e) => {
              // なんかoptionのvalueを数字にしたいときってあるじゃないですか。。
              // そんなときはフォームサブミット時に "1" じゃなくて 1 になって欲しいのでこうしました。
              // むしろ数字にしたいことの方が多いと思ったのでデフォルトで数字ってことにしています。
              // valueを文字列にしたいときはvalueIsString: trueのpropsをあたえてあげてください
              const v = e.currentTarget.value;
              if (valueIsString || !v) {
                onChange(e);
              } else {
                setFieldValue(field.name, Number(v));
              }
            }}
            onBlur={onBlur}
          >
            // 一番上に空の選択肢を入れるかどうか
            {blank && <option>{}</option>}
            // 描画のタイミングの都合でdataが入ってなくてもエラーが出ない安心設計
            {(data || []).map(({ label, value }) => (
              <option value={value} key={value}>{label}</option>
            ))}
          </SelectStyled>
          // エラーがあればエラーメッセージ表示
          {error && <ErrorMsg>{meta.error}</ErrorMsg>}
        </>
      );
    }}
  </Field>
);
// 【④】ラジオボタン
export const Radio = ({ name, data }) => (
  <Field name={name}>
    {({ field: { value: formValue, ...field }, form, meta }) => {
      // onChangeは自分で書く
      const { onBlur } = field;
      const error = meta.touched && meta.error;
      return (
        <>
          {(data || []).map(({ value, label }) => (
            <label key={value}>
              <RadioStyled
                type="radio"
                value={value}
                name={name}
                checked={formValue === value}
                error={error}
                // checkedの判定周りのせいでこれがないとラジオボタン押せなかったりする。。。
                onChange={() => {
                  form.setFieldValue(name, value);
                }}
                onBlur={onBlur}
              />
              {label}
            </label>
          ))}
          // エラーがあればエラーメッセージ表示
          {error && <ErrorMsg>{meta.error}</ErrorMsg>}
        </>
      );
    }}
  </Field>
);
// 【⑤】チェックボックス
// 複数選択可にしたかったのでFieldArrayという子を使う
// childrenのpropsがFieldと全然違うので注意
export const Check = ({ name, data }) => (
  <FieldArray name={name}>
    {({ form: { values }, ...arrayHelpers }) => (
      <div>
        {(data || []).map(({ value, label }) => (
          <label key={value}>
            <input
              type="checkbox"
              value={value}
              checked={(values[name] || []).includes(value)}
              // フォームとして持っている値(配列)をonChangeで追加したり削除したり
              onChange={(e) => {
                if (e.target.checked) arrayHelpers.push(value);
                else {
                  const idx = values[name].indexOf(value);
                  arrayHelpers.remove(idx);
                }
              }}
            />
            {label}
          </label>
        ))}
      </div>
    )}
  </FieldArray>
);
わりとややこし目ですが一回作ってしまうと、一番上でお見せしたように、View部分がすごくシンプルになります!
今回はデザインガン無視なのですが、同じデザインを全画面で使うよって場合はカスタムコンポーネントでデザインを作り込んで汎用的に使えるかと思います!
あ、チェックボックスのエラーメッセージの表示と赤線で囲うやつ、存在を忘れていました。。
呼び出し方
<UserForm
  user={user} // フォームの初期値
  formItems={formItems} // 選択肢のデータ
  registerUser={(u) => updateUser(u)} // 登録実行の関数
/>
所感
一応きれいになりました!
SPAでフォームやるとめっちゃしんどい感が否めない。。。フロントでもサーバーでもバリデーションするの?ってなるけど、サーバーは保存データに直結するのでバリデーションしないわけにはいかないし、かといってフロントでバリデーションせずサーバーだけにおまかせするとSPAらしいUXを犠牲にすることになるしで、結局2重でバリデーション作ることになりますね。。。
高度なUXが求められるようなシステムじゃなくて、CRUD的な「フォームからの情報登録」「登録情報の表示」しか機能がないようなシステムの場合、結局SPAでないRailsやらLaravelが一番開発効率いいのでは、って思ってしまいます。笑
この辺についていい感じの持論をお持ちの方がいらっしゃいましたら是非コメント欄でご教示いただきたいです!