🚒

React Hooksを最大限利用したFormの実装🔥

2021/01/17に公開

2021.10.01追記

記事執筆時点からこの記事の実装についての筆者のスタンスが変わってきたので補足します。伝えたいのは以下です。

プロダクトでフォーム実装を考える際は、React Hook FormやReact Final Formの導入も検討ください。この記事はReactとTSの実装を理解するのには役立ちますが、プロダクト実装に耐えうるかはわからないです。

以下、お気持ちです。

執筆当時、筆者はReactでのフォームの実装は公式にあるようなコントローラブルな本記事の実装一択だ、これが一番拡張性があり、可読性も高い実装だと思っていました(正直視野狭かったですね…)
実際にプロダクトで運用してみると、入力内容の監視を行うとパフォーマンスがめちゃくちゃ悪くなったり、項目を追加させるようなフォームでは秘伝のタレみたいな実装をせざるおえなかったり、結局先に挙げたようなデファクトスタンダードなライブラリを利用するのがよかったんじゃ、、と最近は思ってます😢まだしっかり試せていないですが、機会があればライブラリを組み込んだ実装にチャレンジしてみたいと思ってます。

Reactの実装をプロダクトで採用する機会は増えたと感じてます。この記事にたどり着いた方が、実装についてよりよい検討ができるようにと思い追記しました…!

追記ここまで


Form周りの実装を、React Hooksを最大限利用して管理しやすくしたので軽くまとめます✌
参考になれば✌

最後に全部載せのCodeSandboxのURL載せるので、お急ぎの方はそっちみちゃってください💪

目指すとこ

  • 型定義がんばる
  • コンポーネントはUIの関心だけ持つ
  • inputによくある単位の表示とかも実装
  • ラベルをクリックしたらinputにフォーカスする
  • requiredなどの入力制約の表現はブラウザ標準のものを利用する
  • submitは{プロパティ名: 値, ...}の形式でフォーム全体の値をとれるようにする
  • 親コンポーネントから渡ってくるclassNameもちゃんと受け取る
  • ラジオボタン、チェックボックスはまた今度🙇

やっていき

※ 全体を通して React.FCXってのを使っているのですが、classNameを受け取る際の型定義を省くために React.FCをカスタマイズしているためこうなってます。
カスタマイズについて知りたい方はこちらみてみてください
https://qiita.com/Takepepe/items/f66c7e2e1d22b431f148

※ classNameに指定しているのはemotion/css を代入した変数です。実際の記述部分は「style...」みたいな感じで省略します。

input

Input.tsx

type Props = InputHTMLAttributes<HTMLInputElement> & {
  unit?: ReactNode;
};

const Input: React.FCX<Props> = (props) => {
  const { className, unit, sizes, ...inputProps } = props;
  return (
    <div className={${className || ""}`}>
      <input {...inputProps} className={input(unit !== undefined)} />
      {unit && <span className={unitStyle}>{unit}</span>}
    </div>
  );
};

export default Input;

// style...

ポイント

  • InputHTMLAttributes<HTMLInputElement>使うことでinputタグに使えるプロパティの型定義を楽してます
  • unitはinputタグで一緒に使いがちな「単位」を受け取って表示するために置いてます
  • className || ""ってとこ、もっと良い書き方ないもんですかね・・・

hooks.ts

export const useInput = (
  initState?: string | null,
  inputProps?: InputHTMLAttributes<HTMLInputElement>
) => {
  const [value, setValue] = useState(initState || "");
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
    setValue(e.target.value);
  const id = useMemo(() => uuidv4(), []);

  return {
    ...inputProps,
    value,
    onChange: handleChange,
    id
  };
};

ポイント

  • inputタグに必要なプロパティをすべて返しています。
  • const name = useInput() <input {...name} />みたいに流し込むだけで使えます
  • labelタグのfor(htmlFor)プロパティに指定するときに扱いやすいよう、ユニークなidを付与して返しています。idの発行はuuidというパッケージを利用しています。
  • コンポーネントの更新のたびにid作り直すのもあれなので、useMemoしてます
  • changeイベントにもちゃんと型定義してます。えらい👏

select

Select.tsx

type Props = SelectHTMLAttributes<HTMLSelectElement> & {
  options?: Option[];
};

const Select: React.FCX<Props> = (props) => {
  const { options, className, sizes, ...selectProps } = props;
  return (
    <div className={`${className || ""}`}>
      <select {...selectProps} className={select}>
        <option value="">選択してください</option>
        {options?.map((option) => (
          <option key={option.id} value={option.value}>
            {option.text}
          </option>
        ))}
      </select>
    </div>
  );
};

export default Select;

// style...

ポイント

  • SelectHTMLAttributes<HTMLSelectElement>
  • 表示するオプションは、必要なデータを受け取ってコンポーネント内で表示してます
  • Optionは、hooksファイルで定義してます。のちほどでます
  • <option value="">value=""としとかないと、requiredが仕事しない。しらんかった
  • オプションのマップ時に指定するkey用にidがついてきてます。カスタムフックスがいい仕事してるみたいですね

hooks.ts

export type Option = {
  id?: string;
  value: string;
  text: ReactNode;
};

export const useSelect = (
  initState?: string | null,
  selectProps?: SelectHTMLAttributes<HTMLSelectElement>,
  options?: Option[]
) => {
  const [value, setValue] = useState<string>(initState || "");
  const handleValue = (e: React.ChangeEvent<HTMLSelectElement>) => {
    setValue(e.target.value);
  };
  const id = useMemo(() => uuidv4(), []);

  const optionsWithId = useMemo(() => {
    return options?.map((v) => {
      if (v.id) return v;
      v.id = uuidv4();
      return v;
    });
  }, [options]);

  return {
    ...selectProps,
    value,
    onChange: handleValue,
    id,
    options: optionsWithId
  };
};

ポイント

  • selectタグに必要なプロパティをすべて返しています
  • labelタグのfor(htmlFor)プロパティに指定するときに扱いやすいよう、ユニークなidを付与して返しています。idの発行はuuidというパッケージを利用しています。
  • 前述の通り、オプション表示の際に指定する必要のあるkeyのために上記同様idを付与して返しています。オプション配列にもともとidがある場合は必要ないと思います。
  • コンポーネントの更新のたびにid作り直すのもあれなので、useMemoしてます
  • もしバックエンドからオプションの配列を受け取って表示用に整形するとしたら、この中でやるんだと思います

フォームでの使い方

Form.tsx

const Form = () => {
  const name = useInput(null, { placeholder: "名前", required: true });
  const age = useInput(null, { placeholder: "年齢", type: "number", min: 0 });
  const from = useSelect(null, { required: true }, [
    { value: "Tokyo", text: "東京" },
    { value: "Osaka", text: "大阪" },
    { value: "Kyoto", text: "京都" }
  ]);

  const submitValues = useSubmitValues({ name, age, from });

  const submit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log({ submitValues });
  };
  return (
    <form onSubmit={submit}>
      <Label htmlFor={name.id} required={name.required}></Label>
      <Input {...name} />
      <Input {...age} unit="" />
      <Label htmlFor={from.id} required={from.required}></Label>
      <Select {...from} />
      <button type="submit">送信</button>
    </form>
  );
};

export default Form;

ポイント

  • 宣言的?な利用の仕方してます。inputの状態への関心をすべてname, age, fromのような変数が保持しています
  • useSubmitValuesを通して{プロパティ名: 値, ...}みたいな形に整形してます。後述しますが名前これじゃなくてよかったかも

hooks.ts

// [TODO] 型つけたい
export const useSubmitValues = (states: any) => {
  const values: any = {};
  Object.keys(states).forEach((key) => {
    values[key] = states[key].value;
  });
  return values;
};

ポイント

  • 受け取ったプロパティのプロパティ名(key)とその値でオブジェクトを生成して返しています
  • useInput, useSelectでは必ずvalueを返しているので成り立ってます。そんな性善説でやってけるのか?valueなかったらどないするんや?なんか制約設けないと破綻しそう
  • そもそもブラウザのフォーム周りのAPI利用すればこんなことしなくてもすむ気もしています。まあ送信用のデータ整形ポイントということで…

全部盛り

※見た目の調整のために記事内のコードと違う点ちょいちょいあります
※Input,Selectそれぞれで、個人的によく使うsize調整のための実装が入ってますがまあ無視してください

以上です

以上です。

普段からCodeSandboxでuseHogehogeみたいなカスタムフックを作ってはTwitterで共有とかしてるので、よかったらフォローしてあげてください。フォロワー数で指原を超えるのが夢です

https://twitter.com/ksyunnnn

もっとこうしたらいいよ〜とかあれば教えてください!
ちなみに{...name}みたいな書き方は、よくある規約だと弾かれちゃうんですが、メリットのほうが大きいかなと今のとこ思ってて使ってます。。

観点としてここ漏れてない??みたいなのも是非に〜🙏

お読みいただきあざした!

Discussion