のらねこの気まま暮らし

技術系だけど、Qiita向きではないポエムとかを書くめったに更新されないヤツ

宣言的にFormのルールを定義できるdeclaformというライブラリを書いている

まえがき

2年ほど前から実務でRemixを利用している。 使い始めた当時はライブラリも情報もなく、かなり手探りで始めた開発だった。

当時はプロダクトのMVP検証を目的として、手数少なく動くものが作れるという点でRemixを採用した。 実際に使ってみて、その期待は間違ってなかったと感じたし、今も結構手に馴染んでいる。−−不満はあれど。

そんな中でFormのバリデーションについては、あまり深く考えずにRemix Validated Formを採用していた。

当時は選択肢が多くなく、Remix専用となるとこれくらい。 あとは、React Hook Formを使っていくのが無難だっただろう。

最近だとConformなんてのもできており、Form周りのライブラリは何年立っても賑わうものだなぁと感心する。

そんな渦中に一石を投じるほどの意欲はないが、それでもどうしても物申したかったのでゴールデンウィーク返上で筆を取ることにした。

React界隈におけるForm Validationの課題

−−言えるほど偉い立場ではないんだけども。 −−言うほどReact使ってサービス作ってないんだけども。

Formという箱の中にしか書けないルール

例えば、React Hook Formを使かおうと思うとこういうコードになる。

export default function App() {
  const { register, handleSubmit } = useForm<{ username: string, password: string }>()
  
  const onSubmit = (data) => console.log('submit', data)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("username", { required: true })} />
      <input {...register("password", { required: true })} />
      <button type="submit">Login</button> 
    </form>
  )
}

この場合、useFormを実行することが前提となるので、これらの子となるinput要素はこのuseFormの結果を子まで伝搬させる必要がある。 Componentを切ろうとしても、PropsにReactHookFormの子であることを示す値を設定する必要が出てくる。

また、正しくuseFormしてない箇所に記述はできない。 任意のFormの下でしか動作しないInput要素ということになる。

これは依存性が逆転していると感じている。

Remix Validated Formの場合はこう

import { Form } from 'remix-validated-form'
import { withZod } from '@remix-validated-form/with-zod

const validator = z.object({
  username: z.string(),
  password: z.string(),
})

export default function App() {
  const { register, handleSubmit } = useForm<{ username: string, password: string }>()
  
  const onSubmit = (data) => console.log('submit', data)

  return (
    <Form onSubmit={onSubmit} validator={withZod(validator)}>
      <input type="text" name="username" />
      <input type="password" name="password" />
      <button type="submit">Login</button> 
    </Form>
  )
}

これも似たような、あるいは同様の問題を孕むことになる。 RemixValidatedFormにおいてはvalidationが完全に上位のProviderに依存するので完全に暗黙的な依存を持つことになる。

useFormFieldフックをPresentationalなComponentで呼び出すことになる。そしてStorybookで死ぬ。

これらの構造的制約によって、アプリケーションの開発の幅を狭めざるを得ないことが増えていく。 例えば、Input要素はForm要素の子でなければならない、などの制約がDOM上に露出していくことになるわけだ。それを避けるためには開発者ががんばって頭を捻る必要がある。

とてもつらい。

Schema based validationという外側の制約

React Hook Formは特段使わなければ気にならないかもしれない。

少なくとも、Schema basedなバリデーションを実施しているFormライブラリは最上位にSchemaを食わせる必要がある。

これはInputのnameとruleが外側の何某かによって成約されると言え、要素単位でバラして再利用することをあまり考えていない。 特に業務システムのような複雑な入力フォームを無数に持つ画面の開発において、Form単位でしか共通化できないというのはかなりナンセンスといえる。

重ねて課題を述べるなら、RemixにおいてはForm=Actionの単位なので、無数に一画面にFormにいれたくない。 ある画面では3つですむが、ある画面では5まで関連する要素が増えるみたいなときの共通化を少しは考えさせてほしい。

そのschema、他で使う?問題

最初のころはFormとServerでのバリデーションを共通のSchemaを利用して記述していた。 まあそのままサーバーに送られてくるんだし、問題ないよね。

−−そんなわけなかった。

クライアントとサーバーのバリデーションは意図が異なる。

クライアントでは即座にユーザに問題をフィードバックすることで体験を改善することができる。 パスワードの再入力なんかはは良い例で、クライアントでは必要だけど、別にサーバーでチェックすることはない。

サーバーでのバリデーションは防御的な側面がある。これ入力されちゃマズい、を弾くためだ。 別に処理に関係ないならそもそも使わないしね。

そして、画面を埋めつくするようなフォームを設計する場合、そのフォームはフラットに一つのSchemaなのではなく、複数のSchemaを組み合わせて使うことになる。 つまり、そこにモデルが存在している。

それらを総合的に考えていくと、結局Client Formに向けた専用のSchemaをForm毎に作ることになる。FormのComponentとは別に。

つまり、ただただ関心を分離させているだけと言える。

宣言的ではない

React Hook FormやConformを見るに、hooksでいい感じにパラメータをうめこみますよ〜という手続き的な記述を多用する。 わかるんだよね、便利だし、型制約効くし。

でもそれじゃなんのためにJSXでMarkupしてんのかわからんだろがいという気持ちになる。

ルールを宣言させてくれ。

宣言的にFormを書きたい

色々懸念や課題や愚痴を並べてはいるけど、解決したいのはこれ。 書き心地よくサクッと実装したい。

なので、今回目指したのは「宣言的にかけるForm Validation」

github.com

使い方

  1. バリデーションルールを定義する
  2. フォームを定義する

バリデーションルールの定義

Form単位ではなく、Input(入力)単位でルールを記述する。 ZodとかYup使ってもいいし、使わなくてもいい。

import { defineValidationRule } from '@declaform/core'

const usernameSchema = z.string().min(1).max(64)
defineValidationRule('username', {
  validate(value) {
    return usernameSchema.safeParse(value)
  }
})

const passwordSchema = z.string().min(8).max(64).regex(passwordRegex)
defineValidationRule('password', {
  validate(value) {
    return passwordSchema.safeParse(value)
  }
})
defineValidationRule('passwordCOnfirm': {
  validate(value, data, { ruleReferenceInputName }) {
    return passwordSchema
      .refine(() => {
        return value === data[ruleReferenceInputName]
      }, {
        message: 'Password mismatch'
      })
      .safeParse(value)
  }
})

Formを記述する

単純なケースではあるけど、前提として何らかのComponentにそれぞれのInputやFieldを隠蔽することを想定している。 そのため、カスタマイズが効くようにrenderPropsで受け渡しできるようになっている。

import { HTMLFormElement } from 'react'
import { FormProvider, FormInput } from '@declaform/react'

const App = () => {
  const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
    // 型の整合性に責任は負わないので、使う側で精査してね
    const formData = new FormData(ev.target)
  }

  return (
    <FormProvider onSubmit={handleSubmit}>
      {({ ref, onSubmit, hasError }) => (
        <form ref={ref} onSubmit={onSubmit}>
          <div>
            <label>User ID</label>
            <FormInput name="user_id" rule="username">
              {(props, errors) => (
                <div>
                  <input {...prop} type="text" autoComplete="username" />
                  {errors.map((error) => (
                    <div key={error}>{error}</div>
                  ))}
                </div>
              )}
            </FormInput>
          </div>
          <div>
            <label>Password</label>
            <FormInput name="password" rule="password">
              {(props, errors) => (
                <div>
                  <input
                    {...prop}
                    type="password"
                    autoComplete="new-password"
                  />
                  {errors.map((error) => (
                    <div key={error}>{error}</div>
                  ))}
                </div>
              )}
            </FormInput>
          </div>
          <div>
            <label>Password (confirm)</label>
            <FormInput
              name="password_confirm"
              rule="passwordConfirm"
              ruleReferenceInputName="password"
            >
              {(props, errors) => (
                <div>
                  <input
                    {...prop}
                    type="password"
                    autoComplete="new-password"
                  />
                  {errors.map((error) => (
                    <div key={error}>{error}</div>
                  ))}
                </div>
              )}
            </FormInput>
          </div>

          <div>
            <button type="submit" disabled={hasError}>
              Submit
            </button>
          </div>
        </form>
      )}
    </FormProvider>
  )
}

ガチガチにComponentに隠蔽したらこうなる。

    <Form onSubmit={handleSubmit}>
      {({ hasError }) => (
        <FormField label="User ID" name="user_id" required>
          <FormInputUsername />
        </FormField>
        <FormField label="Password" name="password" required>
          <FormInputPassword />
        </FormField>
        <FormField label="Password(confirm)" name="passwordConfirm">
          <FormInputPasswordConfirm referencePropName="password" />
        </FormField>
        <FormField>
          <Button type="submit" disabled={hasError}>Submit</Button>
        </FormField>
      )}
    </Form>

つまるところ、分解しやすく、組み合わせやすいところまでFormの制御要素を整理したいわけよ。

おわりに

−−という未来を目指して書いてるライブラリ、 declaform の解説でした。 まだ完成してない。絶賛道半ばです。

実際やってみて、2010年代のフロントエンド開発とは明らかに難しさが違うな〜と感じている。

TypeScriptとReactのかけ合わせでライブラリ書こうとするとめちゃくちゃむずい。 ライブラリ化せずにただ動くものってレベルだと半日くらいでできていた。型とStateの伝搬の整合性とるのに数日かかった。まじむずい。

なんで、まあ、しばらくしたら「無理っす、Conformつかいましょう」って言ってるかもしれない。 これ一人でなんとかできる気がしない。

世の中のライブラリ開発者の皆さんに、溢れんばかりの感謝と敬意を。