jotai-decode-form
Jotaiを使ったformを定義するためのlibrary アプリケーション内部と外部のデータ構造を厳密に切り分ける
Install方法
$ npm i jotai-decode-form jotai
モチベーションと簡単な使用例
具体的には、以下の2つの要件を満たしてformを実装したい
内部と外部のデータ構造を分けて管理する
内部と外部の境界 (form) で相互に変換する
例えば、通常のJotaiを使ってwidthのような数値を入力させるfieldを定義することを考える
code:ts
import { useAtom, atom } from "jotai";
const widthAtom = atom();
const Field: React.FC = () => {
return (
<>
<label htmlFor="width">width</label>
<input
id="width"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="100"
/>
</>
);
};
ここで、widthはプログラムの内部ではnumberとして扱いたいが、現状の実装だとstringになってしまう
code:ts
const field = useAtomValue(widthAtom);
console.log(field.value); // "100"
console.log(typeof field.value); // string
これは他のform libraryでも同様に起きている問題である
理想的には以下のようなことを行いたい
外部の世界(view)では、stringを扱うが
内部の世界では、numberを扱う
code:ts
import { atomWithSchema } from "jotai-decode-form";
import { useAtom } from "jotai";
import { z } from "zod";
type Width = number;
const widthAtom = atomWithSchema<Width>({
schema: {
fromView: z.coerce.number().safeParse,
toView: String,
},
});
fromViewに、stringから内部のデータ構造に変換するための関数を指定する
この関数はvalidaitonも兼ねることになる
上記の例では、簡単のため無理やりnumberに変換する関数を用いている
toViewはその逆で、内部のデータ構造をstringに変換する関数を指定する
数値を文字列に変換する処理を書いている
この新しいatomの定義を使用すると、通常のatomと同様にuseAtomを使ってアクセスできる
code:ts
const Field: React.FC = () => {
return (
<>
<label htmlFor="width">width</label>
<input
id="width"
value={field.exValue}
onChange={(e) => onChange(e.target.value)}
placeholder="100"
/>
</>
);
};
また、内部の値を参照すると定義通りの型numberが得られる
code:ts
const field = useAtomValue(widthAtom);
console.log(field.value); // 100
console.log(typeof field.value); // number
他のユースケースをいくつか紹介する
変換の必要がないとき
内部: string
外部: string
内外で変換が不要な場合は、特にSchemaを指定せずに使える
code:ts
type Name = string;
const nameAtom = atomWithSchema<Name>();
数値に変換するとき
内部: number
外部: string
code:ts
const Age = z.number().min(0).max(100);
type Age = z.infer<typeof Age>;
const widthAtom = atomWithSchema<Age>({
schema: {
toView: String,
fromView: NumberFromString.pipe(Age).safeParse,
},
});
内外で数値の単位を変えたいとき
内部: MM
外部: CM
例えば、複数の単位を扱うアプリケーションを書いているとする。
ユーザ視点だと、コンテキストによってcmが相応しいときもあれば、mmが相応しいときもある
一方で、プログラムの内部でそれらを混合した計算を行う場合は、一つの単位に揃っている必要がある
そこで、以下のような例を考える
内部のデータ構造は統一的にMMを扱うが、
widthのfieldではCMを使う
事前に以下のような型をbranded typesで定義しておく
code:ts
// MMの定義
type MM = z.infer<typeof MM>;
const MM = z.number().brand<'MM'>();
// CMも同様に定義
// 相互に変換する関数を用意
const cm2mm = (cm: CM): MM => mkMM(cm * 10);
const mm2cm = (mm: MM): CM => mkCM(mm / 10);
Height型はmkMMを用いて以下のように定義できる
code:ts
const Height = z.number().min(0).max(1000).pipe(MM);
type Height = z.infer<typeof Height>;
さらにそれを使って、heightのfieldを定義する
code:ts
const heightAtom = atomWithSchema<Height>({
schema: {
toView: mm => (mm == null ? '' : ${mm2cm(mm)}),
fromView: NumberFromString
.pipe(CM)
.transform(cm2mm)
.pipe(Height)
.safeParse,
},
});
fromViewでは記述の通り、
string→number → CM→MM→Heightのように変換を行っている
toViewではMMを文字列のCMに変換している
実際の挙動としては、例えば、
ユーザは100(cm)と入力するが、
アプリケーション内では1000 (mm)を扱うことができる
同様の例として、内部ではUSTを使うが、外部ではJSTを使う例も用意した
選択肢の表示が異なるとき
例えばテーマの型をプログラム内部では以下のように定義した
code:ts
type Theme = { mode: "light" } | { mode: "dark" };
UI上のselectboxでいずれか1つを選ばせたい
しかし、selectboxの選択肢はもっとユーザに親和性のある言葉を使いたい
そこで、以下のようにView用の選択肢を別途定義する
code:ts
type ThemeOption = z.infer<typeof ThemeOption>;
内部(Theme)と外部(ThemeOption)を相互に変換する処理を書いて使う
code:ts
import { atomWithSchema, success } from 'jotai-decode-form';
const themeAtom = atomWithSchema<Theme, ThemeOption>({
schema: {
toView: (theme: Theme) => {
switch (theme.mode) {
case "light":
return "ライトモード";
case "dark":
return "ダークモード";
default:
throw new Error(${theme satisfies never})
}
},
fromView: (value: ThemeOption) => {
const parsed = ThemeOption.safeParse(value);
if (!parsed.success) return parsed;
switch (parsed.data) {
case "ライトモード":
return success({ mode: "light" });
case "ダークモード":
return success({ mode: "dark" });
default:
throw new Error(${parsed.data satisfies never})
}
},
},
});
初期値の有無の型安全の保証
atomWithSchemaでは初期値を指定できる
code:ts
const widthAtom = atomWithSchema<Width>({
initValue: 100,
});
初期値を指定したか否かで、内部の値(value)の型がnullableになるか決まる
初期値ありのときは、Tになり、
初期値がないときは、T | nullになる
初期値がない場合はhandlingが強制されるので型安全になる
以下の2つのアイディアを混合させた
todos
これに寄せていく
ESM化
個別のatom、ex, 変換を別個で用意する
そうしないと、別の変換ができない
てか、これだけを独立したlibraryとして提供すればよいのでは?
formに特化する必要がない
これとは独立で、field errorなどの機構も考える
欠陥がある
set死体だけなのに、いったんgetする必要がある
set(get(atom).onChangeInValueAtom, xxx)
そのため、atomEffectなどで不要な反応が生じる
血管がある
他のものへの変換ができない
fieldに具体化しすぎ
aというものを
field用、DB用の2つと同期したい
というときに無理になる
いや、syncするよりも、両方を更新するsetterを提供したほうがいいのではないか?
syncはsyncでバグる余地が大きい
今使っていない機構、いったん全部消そう
isSubmit的なやつとか
atomWithSchemaの内部のatomの変更が検知されないらしい
こういうatomが、atomWithSchemaが変更されたときに変わらない?
code:ts
const hogeAtom = atom<Hoge | null>(
get => get(formAtom).value,
);
atomWithSchema
getを提供しないときつそう
例えば、fieldが2あり、内部の型は1つであるときとか
日付,時間の2つのfieldから、Date系の内部型にしたいとか
こういう便利hooksを提供する
code:ts
const useSetInternalValueAtom = (
atom: WritableAtom<AtomWithSchemaReturn<Value, ExValue>, ExValue, void>, ) => {
return useSetAtom(useAtomValue(atom).onChangeInValueAtom);
};
atomFormの削除
atomFormやめて、普通のatomにできないかなあ
変換のための関数を提供するだけで良いのでは
利用者的にはやや不便になるが、中途半端なatomFormを提供するより良いのでは?
便利関数を提供つつ、通常のatomのgetter/setterを自分で書くのが良いかも
Maybe型相当の型を用いて抽象化すると良さそう
全体的に
CIにtypecheckも追加
atomForm内のschemaのネスト
今はややハック的にこんなふうに書く必要がある
code:ts
hoge: getForm(
atomForm(({ get, getField, getForm }) => ({..})
),
別にサポートしても良いが、型Deepなんちゃらになったり、flattenしたりそのためにsymbolを追加する必要があったり無駄に複雑になってしまう
なんか糖衣構文てきな工夫で↑これに変換できるようにしたりできないだろうか
内部実装は全くそのままで、その前にちょっと変換するだけって感じにしたい
atomFormの型引数の利便性を上げる
型引数がValuesではなく、内部の型になってる
前直したがその後の改善で再度無理になった
原理的に不可能かもしれない
言うてさほど困らない気もする
逆なので
逆にatomFormの定義からForm schema用の型を取り出すutilがあると便利
zodのTypeOf的なやつ
リリースをもうちょいちゃんとしチア
この辺の運用方法を知らん
git tagとか
外部の型が常にstringとも限らん
これは優先度低い
例えば数値の選択肢とかありうる
リストをクリックして選択したid (number)を保持するとかもある
あるいは、その場合はわざわざatomWithSchemaを使う必要がない(?
そんなことないか
普通にatomを使えば良い
しかしその場合はatomForm側が普通のatomも受け付ける必要がある
atomFormの型を工夫したい
また、hoverした時に表示される型が型関数の適用前なので見ずらい
これどうにかするutility typeとかあったっけ?
test上のinitValue周りの型エラー
onChangeInValueAtomをrename
exValueもか
submit valueいらんくね?
仮にいるとしても公開する必要なくね?
symbolとかで持っとけばいい
dateのexapmleのtoViewの方格の忘れてた気がする
jotai-decode-formをunctonrolledにするとか
setterが2つあるのどうにかできんかね...
hooks側を対応させるという手もある
useReducerAtomみたいに
onChangeInValueAtomしたいだけなのにvalueを監視しないといけないの、パフォーマンス的にも意味が分からなくて良くない
後単純に2行必要になるのもダルい
useAtomValueとuseSetAtomしないといけない
まあこれは専用のAPI用意すれば済むが
実装のメモ