React×Rxでフォームの状態管理をすることの可能性🧐
はじめに
こんにちは。最近にわかにFP、FRP(Functional Reactive Programming)というものにハマって宣言的なプログラミングの面白さや気持ちよさを日々感じているs.katoです。
そんなマイブームの勢いで「RxJS、ReactRxを用いて複雑な副作用を持つフォームの状態管理をいい感じにできるんじゃないか」、ということでReactHookFormのような規模感で内製をしてみたのですが、結構おもしろかったので紹介できたらなと思ってます🥳
やりたいこと
例えば入力フィールドがA、Bで2つ存在し「BはAの値が特定の値の時だけenabledになり、それ以外の時はdisabledで空値になる」という仕様でこれを実装する場合。
素直に実装するとこんな感じになるかなと思います(イメージ)↓
const { register, watch, setValue } = useForm()
const a = watch('a')
const isBEnabled = a === 'enable-b'
useEffect(() => {
if (!isBEnabled) {
setValue('b', '')
}
}, [isBEnabled])
return (
<form>
<input {...register('a')} />
<input {...register('b')} disabled={!isBEnabled} />
</form>
)
これをこんなふうに書きたい(イメージ)
// createSubject()は任意の型の値を流すSubjectを生成する内製ヘルパー
const [aInputEventStream$, onInputA] = useMemo(() => createSubject(), [])
const [bInputEventStream$, onInputB] = useMemo(() => createSubject(), [])
const aInputValue$ = aInputEventStream$.pipe(
startWith(''),
)
const isBEnabled$ = aInputValue$.pipe(
map(v => v === 'enable-b'),
)
const initBValueEventStream$ = bIsEnabled$.pipe(
filter(enabled => !enabled)
)
const bInputValue$ = merge(
bInputEventStream$,
initBValueEventStream$.pipe(
map(_ => ''),
),
).pipe(
startWith(''),
)
// ...
正直初見の読みづらさは悪化してる気はします。ですがこのような実装では、各イベントや値の依存関係がそのままコードに表現されるため、宣言的に書けて意外と仕様を読み解きやすかったりします。
たとえばaInputValue$
は、「aInputEventStream$
と初期値の空文字列から導出される値」だとコード上に明示されており、その定義から外れた振る舞いはしないことが保証されます。
これらのObservableが不変性を持っているため、それぞれのconstによる定義を読めば、その値が何を表すか・どう変わるかがすべてわかる、というのが個人的にすごく好きなポイントです。
また、実装者としては「この値は何なのか?」「この値は何に依存していてどういうふうに導出されるのか?」をつらつら上から書いていく感覚になるかなあと思ってます。
実際、どうやって実装してるの?
今内製してるReactHookForm風の実装はまだまだ荒削りで結構複雑な感じになってるので、おおまかな実装の仕方を紹介します。
まず各フィールドにおいて扱いたいObservableや入力欄にバインドするためのイベントハンドラなどを洗い出してモデル化:
export type FieldInstance<V, E> = Readonly<{
value$: Observable<V>
error$: Observable<E | null>
disabled$: Observable<boolean>
isTouched$: Observable<boolean>
onInput: (value: V) => void
onBlur: () => void
onFocus: () => void
reset: (value: V | undefined) => void
// Observableを作り出すにあたって生成されるSubject群をすべてcomplete()するための関数みたいなもの
closure: SubjectClosure
}>
interface Options<V, E> {
defaultValue: V
// バリデーションの内容も動的に変えられたら嬉しそうなのでObservableで受け取り
validator$?: Observable<(value: V) => E | null>
disabled$?: Observable<boolean>
// ユーザーによる入力での値の更新とは別に、外部への依存を元に値を更新したい場合用のObservable
// ユーザーの入力イベントストリームとマージされる
value$?: Observable<V>
}
export function create<V, E>(options: Options<V, E>): FieldInstance<V, E> {
// StreamWithClosure.create()は独自実装で内部でSubjectのインスタンス化などをして返している
const { emit: onInput, value$: inputStream$, closure: inputStreamClosure } = StreamWithClosure.create()
const { emit: reset, value$: resetStream$, closure: resetStreamClosure } = StreamWithClosure.create()
// ... 略
// 全入力フィールド共通で必要になりそうなObservable群を組み上げる
return { /* ... */ }
}
フォームごとに、FieldInstanceを必要なだけ作って返す関数をつくる。この中で入力フィールド同士の依存関係を組んだりする:
export function constructForm() {
const username = create({ defaultValue: '', validator: /* ... */ })
const password = create({ defaultValue: '', value$: username.value$.pipe(/* ... */) })
return { username, password }
}
Subjectをインスタンス化したりObservableを組み上げるのはレンダリングごとじゃなくてコンポーネントマウント時のみにしたいのと、アンマウント時にSubjectのcomplete()などをしたいのでuseEffect
を使っていい感じに組み上げや開放を行うためのhookを実装:
type Fields = { [key: string]: FieldInstance<any, any> }
interface Args<T extends Fields> {
formConstructor: () => T
}
export function useRxForm<T extends Fields>({ formConstructor }: Args<T>) {
/* ... */
}
FieldInstanceを受け取って、これが持つObservableの最新値をコンポーネント側でとれるようにするためにuseObservable
(ReactRxによる)でアンラップしてあげるコンポーネントを実装
interface Props<V, E> {
of: FieldInstance<V, E>
children: (data: { value: V, error: E, onInput: (v: V) => void, /* ... */ }) => React.ReactNode
}
export function RxFieldController({ children, of, readonly }: Props) {
const value = useObservable(of.value$)
const error = useObservable(of.error$)
/* ... */
return children({
value,
error,
/* ... */
})
}
上記の実装を任意のコンポーネントで呼ぶ
export function SampleForm() {
const { fields } = useRxForm({ formConstructor: constructForm })
return (
<div>
<RxFieldController of={fields.username}>
{({ value, onInput, /* ... */ }) => (
<input value={value} onInput={onInput} />
)}
</RxFieldController>
{/* ... */}
</div>
)
}
実際はもっと複雑な感じになってしまいましたが、だいたいこんな感じの構成で実装してみました。
フォームごとに専用のconstructFormの実装をして、それをuseRxFormに渡し、戻り値のFieldInstance群をRxFieldControllerに渡してObservableの講読をすればOKって感じになってます。
終わりに
Rx自体そもそも学習コストが高かったり、既存のReact×Rx×Form系のライブラリは軒並み最終更新が数年前だったりと実際に導入するには結構敷居が高く、インクリメンタルサーチを実装したい時くらいしか選択肢に上がらないRxによるフォームの実装ですが、FRPの考え方を活用して宣言的に依存関係などを組んで動的なフォームを実装していく感じは結構面白かったし、複雑な要件に対応する上ではもしかしたら有効になる可能性のある択なんじゃないかなと感じました。
今後さらに複雑なケースやUIとどう向き合うかを試しながら、このアプローチを育てていけたらと思っています!
Discussion