アプリケーションの内部に理想郷を築く (form編) @KFUG Web Creators Meetup #2 2022/8/17
15分~ぐらいかかりそう
自己紹介 (~1:00)
mrsekut.icon
mrsekut (まる)
tryangle株式会社
業務ではフロントメインだが、割といろいろやっている
趣味ではHaskellなど
コメント、感想、指摘、補足は、このスライドに直接書き込んでくださいmrsekut.icon
スライドはさっきTwitterに流したmrsekut.icon
リンク付きのオリジナルのスライド資料は以下にあります
アジェンダ (~1:30)
プログラムを書くときに、そもそもやりたいことについて
ちょっと抽象的な話になるかもmrsekut.icon
最近ぶつかった2つの課題について紹介
上記の話の具体例としてmrsekut.icon
その解決策を考えた
主に話したいのはここmrsekut.icon
前提とする知識(?) (~2:00)
以下の技術周辺の話をします
React
TypeScript
react-hook-form
よく使われるform library
zod
よく使われるvalidation library
そもそもやりたいこと(~2:30)
内部と外部の境界とは (~3:00)
https://gyazo.com/141c5002b4fd3e5c576a9061e3fe2e18
外部 ↔ 内部がある境界の例
frontnedなら、APIとか、Formとか
backendなら、APIとか、DBとか
「外部のデータ構造」と「内部のデータ構造」を明確に切り分ける (~4:00)
ざっくり言えば
できるだけ厳格な型を定義しよう
また、その変換は境界部分でやっちゃおう
みたいなことを言っているmrsekut.icon
外部のデータ構造は信用できない
e.g. Formで入力された文字列が常にvalidなデータとは限らない
外部 → 内部のタイミングで、データの構造を変える
e.g. Array<Item>→NonEmpty<Item>の変換を境界でやる
内部世界では、その正しいデータ構造のみを受け付けるプログラムを書く
https://gyazo.com/141c5002b4fd3e5c576a9061e3fe2e18
最近の業務で存在していた課題を2つ紹介 (~4:30)
(よくある課題な気もするので、もし遭遇した経験があればどう解決したか聞いてみたいmrsekut.icon)
長さの単位を統一すべきか
例えば、数値に関する項目が沢山あるアプリケーションのとき
UIに表示すべき単位は、ユーザに最も親和性のある単位であるべき
ものによって異なることがある
Aの高さに関してはcmをよく使うが
Bの長さに関してはmをよく使う
など
しかし、AやBを一緒くたにして内部で計算することがある
その場合に、単位が揃っていないといけない
他の例
タイムゾーン(UTC/JST)とか
Date型の値と、YYYY/M/Dのような文字列とか
どうする?
①統一する
②統一しない
③パッケージごとに変える
長短あるが、今回は①を採用した (~7:00)
内部では全てmmを扱う
UIに表示する際は、mやcmやmmを使う
内部と外部で型を分ける
zod.brand()を使って別物として定義する
code:Unit.ts
// mm
// ================================
export type MM = z.infer<typeof MM>;
export const MM = z.number().brand<'MM'>();
export const mkMM = (mm: number): MM => MM.parse(mm);
// cm
// ================================
export type CM = z.infer<typeof CM>;
export const CM = z.number().brand<'CM'>();
const mkCM = (cm: number): CM => CM.parse(cm); // private
互いに変換する関数を用意しておく
code:Unit.ts
export const cm2mm = (cm: CM): MM => mkMM(cm * 10);
export const mm2cm = (mm: MM): CM => mkCM(mm / 10);
内部で使用する計算ロジックの関数はMMのみを受け付けるようにする
code:ts
const calc = (width: MM, height: MM) => {...}
今回はかなりform過多なアプリケーションを作っているmrsekut.icon
型が正しくならない
react-hook-formの使用感 (~8:30)
こういうSchemaを用意して
code:ts
const schema = z.object({
width: z.string().transform(Number)
})
type Schema = z.TypeOf<typeof schema>; // { width: number }
fieldの入力はstringで来るので、zodでnumberに変換してる
useFormに対して使う
code:ts
import { useForm } from 'react-hook-form'
const { getValues, handleSubmit } = useForm<Schema>({
mode: 'onChange',
resolver: zodResolver(schema),
});
submitした時の処理では、validationされた値が得られる
code:ts
const submit = (value: Schema) => {
// value.widthはnumber型! (嬉しい)
}
<form onSubmit={handleSubmit(submit)}>..</form>
しかし、getValues()やwatch()などを使った場合、型と値がズレる
型は、{ width: number}
値は、{ width: string }
(嬉しくない)
ざっくり言えば、(~9:15)
「submitボタン」を押して送信する系の処理では正しく使えるが、
fieldに何か入力するたびに計算する場合にうまく行かない
e.g. インクリメンタルサーチ
いくつか工夫のしようはあるが、完全には解決されない
zodの責務が大きくなりすぎてreact-hook-formの要件を超えている
遭遇した課題2つのまとめ (結局何がやりたいか) (~9:45)
内部と外部でデータ構造を変えたい
form周りでも、この変換を楽に行いたい
実現するためには以下の4つのパーツが必要 (~10:45)
①外部世界のための構造
ユーザ目線の単位 (MM, CM, Mなど)
※ ただし、stringにスッと変換できる構造である必要がある
②内部世界のための構造
正しいデータ型
今回なら、長さは全てMM
それらを相互に変換する関数たち
③内部→外部
validationも担う
④内部←外部
ちなみに、react-hook-formは②(と④)しかないmrsekut.icon
以上を満たしたhooksを作った (~12:00)
以下のように使う
code:ts
const schema = {
width: {
in: MM, // ①
ex: CM, // ②
i2e: mm2cm, // ③
e2i: cm2mm, // ④
},
heihgt: {
in: MM // ①
}
} satisfies FormSchema;
const Form: React.FC = () => {
const { register, values } = useForm(schema, {
defaultValues: {
width: mkMM(0),
}
});
const width = values.width; // number
const height = values. heihgt; // number | null
return (
<div>
<input {...register('width')} type="number" />
<input {...register('height')} type="number" />
</div>
)
};
ポイント
schmea定義で①〜④を指定できる
特に変換が必要ないなら②~④は省略可
react-hook-formとほぼ同じAPIを用意している
かつ、型に嘘がない
getValue()したら、内界の正しいデータ構造が得られる
型安全である
defaultValuesを指定しなかったfieldの型はT | nullになる
npmに公開した
未実装な箇所も多い
追伸 (~13:00)
上記のような話を知人にしたら更に良さそうなアイディアが得られた
recoilやjotaiのようなpackageを使って定義する
1つのfieldに対し、1つのatomを用意する
かつ、内部用のatom、外部用のatomを用意する
react-hook-form、そもそもform全体を1つの状態で扱おうとしたのが難しくなるポイントだったのでは?
と、いうことで、公開したが↑このアイディアが良さそうならメンテされないかも()
雑談(時間あるなら)の話題 (~14:00)
上記のような問題に遭遇したことがある方、どんな風に解決しましたか?
アプリケーション内で構造を統一すべきかという問題
react-hook-formとzodを併用した際の型の不一致の問題
packageを公開する際の行儀の良い仕草がわからないので知りたい
package内部で依存している別のpackageのversionの扱いとか
bundler周辺とか
どういうfileを公開すべきかとか
時間なさそうmrsekut.icon
まとめ (~15:00)
内部と外部でデータ構造を分ける
境界でデータ構造を変換する
特にフロントエンドはデータの入出力が多すぎて大変
form部分の課題に向き合った
フォームのライブラリ2系統ある
テンプレート駆動
reactではあまりない?
vue, angularではあるあるだった(?)らしい
モデル駆動
react-hook-formとかはバリバリこっち
packageに関して
package.json内で17 or 18 みたいに書くとか
利用者側に内部でオーバーライドするとか