タオルケット体操

サツバツいんたーねっとでゲームとかガジェットのレビューとかをします

PROのためのスーパー型が付くReactフォームライブラリをつくった

Sponsored link

GitHub - hachibeeDI/mayoiga

hachibeedi.github.io

つまりReactフォーム界のHANAMASAということ。

良い特徴、あるいはReact Hook Formとの違い

1. refに依存していない

自作するとわかるのですが、Formの状態管理(およびバリデーション)は素朴な実装だとあっという間にUIをブロックするレベルでパフォーマンスが劣化します。
Reactが難しい、と言われる一つの原因ですね。

React Hook Formは各inputをUncontrolled Formとする代わりに、状況に応じてDOMを直接操作することで、普通のフォームであれば適当に実装してもパフォーマンス劣化が起こりにくいです。
昔はrefを直接渡していたようにおもえますが、いまは <input {...register('hoge')} /> というようにインターフェースを隠蔽することが推奨されています。もしかしたら将来的にrefを排除することも考えている可能性はあります*1が、少なくとも直近のバージョンではそうでした。

APIがシンプルでパフォーマンスも素晴らしい、いいことづくめじゃん!と思われるかもしれませんが、当然デメリットがあります。
というかちょっと使い込んだらデメリットがモリモリと明らかになったのが今回フォームライブラリを自作した理由の一つです。

例えば……refに依存している以上、中身の挙動がDOM操作なので初期値の導入まわりでReactのlifecycleと競合を起こします。
またuncontrolledな中身をref越しにどうやって操作しているのかはブラックボックスです。なので useImperativeHandle を使っていると意味不明なエラーが出まくります。
またこの シンプルなAPI は簡単なフォームの作成に特化しているので、refを公開しないような自作あるいはサードパーティの複雑なformを使い始めた瞬間にコード量が増え、APIは非直観的なものになります。

これらを解決するために作ったのがMayoigaです。
ゆえに「 useImperativeHandle なんてつかわねーよ!」というプロジェクトであれば、あるいはrefへの依存に居心地の悪さを感じないのであればHook Formで十分かもしれません。

2. バリデーション前と後で違う型をつけられる、その他の場所でも型がめっちゃつく

はい。

この「フォームのバリデーション前と後で違う型をつけられる」という機能をサポートしているライブラリをみたことはないです。個人的には不思議でなりません。
日付の入力なんかは、inputの種類によってはバリデーションをかけるまでそれがISO準拠の正しいDatetime formatであるのかは検証不可能ですし、パフォーマンスの観点から言っても入力中はstringとしてstateに保持されているべきです。
逆にバリデーションをかけたあとはDate型か、少なくともValidなテキストフォーマットであることを保証したくなりますよね?

Mayoigaはzodの定義をschemaとして渡せます*2
zod.transform を使えばluxonのような任意の型やBranded stringで定義できるため、安心してsubmit handleできちゃいます。
このおかげで、例えば「ページ離脱前に入力中の値をキャッシュしておく」みたいなのも型安全にかけるわけですね。やったぜ。

また、恐らくですがHook Formは簡潔なAPIと利便性の両立のために色々なところで型安全を諦めています。気を抜くとすぐにanyが湧いてきます。

Mayoigaは(僕がトチってなければ)全ての操作を型安全に行えます。例をあげましょう。
useFormSlice というform controllerを受け取ってstateのsliceを取得するヘルパーがあります。

const schema = zod.object({
  name: zod.string(),
  isPro: zod.literal(true),
});

const initialState = {name: '', isPro: false};

const {controller} = createFormHook(initialState, schema);

function ProChecker() {
  const [isPro, {handleChange}] = useFormSlice(controller, (s) => s.value.isPro);
  return (
    <input type="checkbox" checked={isPro} onChange={(e) => {
      handleChange('isPro', e.target.value); /* うっかりvalueを参照してしまった!!! しかしvalueはstringを返すため、ここは型エラーになる
        Argument of type 'string' is not assignable to parameter of type 'boolean'.ts
      */
      handleChange('isPro', e.target.checked); // :ok_hand:
    }} />
  );
}

その他、<Field name="isPro">{(tool) => <input {...tool} />}</Field> のような書き方もできます。APIがださい?ほっといてくれ

3. 充分早い、チューニングもしやすい

refなしのcontrolled component前提ライブラリですが、充分高速で動作します。
また、no magicなのでReactやReduxをちゃんと理解しているプログラマーならば複雑なフォームの動作をチューニングするのは容易です。

4. コードが短いし超シンプル

シンプル(個人の意見)。

動作のチューニングが簡単な理由の一つがこれです。
ぶっちゃけやってることはexternal storeを作ってactionから更新、各所はselector越しに値を参照している……ただそれだけです。
だからコメントをいれても総コード行数は350行程度です(まだいくつかエラーハンドリングまわりの機能を追加する予定はあるけど、それでもそこまで増えたりはしないはず)。

<Field /> というコンポーネントもありますが、これはただのSliceのショートハンドです。

stateのどの部分をどこでどうsliceするのか、Mayoigaのパフォーマンスチューニングはただそれだけです。
小規模で独立したRedux stateを作っていると捉えていただいて結構です。

フォームライブラリみたいな軟弱なものはいらねえんだよ!俺は都度自分でフォームの状態を管理するコードを書くね!!!
そういうPROであれば一度触ってみてもいいかもですね?

Mayoigaのよくないところ、あるいはReact Hook Formと比べて劣っている点

1. なんか僕一人が勝手に作っているだけ

僕個人が勝手に作っているだけ、いわゆる野良ライブラリです。
Hook Formとは規模が違います。
単体テストの網羅性、利用者の数、要望に対する修正の早さ……全てで劣っていると言わざるを得ません。

といっても350行なんで、仮に僕が失踪したり飽きたりしてもフォークするなりでメンテできる範囲です。
フォームの状態管理程度のことで、読んで理解できない、作者が失踪したらメンテできない、そんな規模のライブラリを採用するのは間違いだと僕は考えています。特にフロントエンドではね。

一応、業務で使っているものなので僕が使う範囲の機能*3や安定性は確保されます。

2. React18、zodが必要

PROであれば全員もうReact18にアップグレードしてるはずなのでここは問題ないですね。

zodしか対応していない、ここは諦めてください。
zodが現状では一番優れています。

React Hook Formはresolverという形で色々なパーサーに対応していますが、そのせいでzodのバージョンをあげるたびに高確率で動作が壊れます。
ここは割り切りましょう。割り切ったおかげでMayoigaのコードはシンプル、バリデーション周りのバグもほぼ存在しない。だって全部丸投げしてるから。

3. ドキュメントがダメ

大変ですね。
使いたい、という地雷踏みが趣味の一風変わった方はコードを読む必要があります。

でもフロントエンドのサードパーティ、特にcomponent系のライブラリは酷い品質のものが多いのでコードを読む習慣が身につくのは逆にアドですね!

4. Contextが提供されてない!シングルトンなんだけど!!

古来より、GUIのアーキテクチャパターンでstateをシングルトンで扱うのは普通のことです(これはマジ)。
Contextはマイルドなシングルトン + DIって感じですね。

useForm した内部でrefやキャッシュを使って制御するようなAPIは可能ですが、作者はそこに価値を感じないのでそれはやりたい人がラッパーを書けばいいですね。
単体テストを書きたい場合は、後述するパターンで分割すれば依存性を排除してかけます*4

Contextも同様に、必要であれば自分で書きましょう。
formHook.controller がimmutable singletonなので、selector越しにsliceを取得すればContext特有のパフォーマンス劣化は起きません。

Tipsですが、controllerを受け取る側は意図的に狭い型を宣言することで再利用性を高めることができます。

ためしに先ほどの <ProChecker /> をリファクタリングしてみましょう。

function ProChecker(props: {controller: Controller<{isPro: boolean}>}) {
  const {controller} = props;
  const [isPro, {handleChange}] = useFormSlice(controller, (s) => s.value.isPro);
  return (
    <input type="checkbox" checked={isPro} onChange={(e) => {
      handleChange('isPro', e.target.checked);
    }} />
  );
}

Propsが Controller<{isPro: boolean}> を引くようになっていますね。
先ほどの親stateは {name: string; isPro: boolean;} でしたが、ProCheckerが必要なのは isPro だけなので必要最低限の宣言だけ行います(TypeScriptの型システムは親がリッチな分にはコンパイルエラーを出しません)。

こうしておけばProCheckerは isPro: boolean を含むcontrollerであればどのようなformであっても再利用することができる、あるいは親の他のフィールドの変更を受けない、わけですね。
もちろん単体テストもやりやすくなります。

5. 初心者にやさしくない

まず初心者はReactサポート系ライブラリを使うべきじゃない。なんなら上級者は使わんでいい。じゃあなんで作った。
老害マウンティングとかじゃなくて、これはマジの親切心で言ってます。

ちなみに理由ですが、フロントエンドのライブラリはだいたいがコードの品質が低くてすぐにメンテされなくなるからです。
だから使ってるライブラリのコードを読んでやってることが理解できない初心者は入れるべきじゃないし、上級者なら自分で書いたらええやん?

DnD APIとか、CSS in JSとか、なんかのサービスのSDKとか、そういう自分でやるとマジでめんどくさいやつだけにするのがいいとおもいますね。いやマジで。

まとめ

  • 作者はReact Hook Formを使い込んだ結果、refまわりの謎挙動によるストレスで発狂してキーボードがすり減った経験があるらしい

  • 型がめっちゃ効くのですごい安心

    • バリデーション前後で型定義をわけられるとすごい便利っぽい
  • ぱっとみのAPIはReact Hook Formほどかっこよくない

    • その分カスタマイズ性が高いからいろんなプロジェクトで導入できると作者は考えているっぽい
  • PROならば動作はチューニングできるっぽい

  • 350行くらいで実装されてるらしい

    • 唯一dependenciesに入っているnozuchiっていうライブラリも作者製っぽい
  • 業務で使ってるから当分はメンテされるっぽい

  • 作者はドキュメントや単体テストを書く暇がないらしい

こちらからは以上


ちなみに作者イチオシのゲーミングキーボードはSteelSeriesのApex Proです。名前にProとついているあたり、あからさまにPROのためのキーボードです。
まじさいきょー

SteelSeries ゲーミングキーボード テンキーレス 有線 日本語配列 OmniPointスイッチ 有機ELディスプレイ搭載 Apex Pro TKL JP 64737

SteelSeries Apex Pro TKL メカニカルゲーミングキーボード – 世界で最も速い機械スイッチ-有機ELのスマートな表示-コンパクトなフォームファクター-バックライトRGB

*1:refなしであのシンプルなAPIを保てるとはおもえないですけども

*2:React Hook Formみたいにresolverを書いてもいいですが、あれはそのせいでzodがアップデートしにくくて本当にダメ

*3:大体出揃ってますが

*4:作者はcomponentの単体テストにあまり意味を感じないタイプです