@reaxon/hook-form-effector 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +332 -30
- package/package.json +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @reaxon/hook-form-effector
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 3f2672e: Rewrite the README around the package's ideology: react-hook-form owns the form, the binding exists for the model to read it (`formState`) or drive it (effect factories or your own `attach`). Adds a quick start with the control held in a `$form` store, a full API reference, and recipes for custom effects, derived stores, edit-dialog prefill and reset, server validation errors, dependent fields, and handing a UI-created form to the model through a Gate. Documents scope and SSR rules and the compiler plugin `factories` setup.
|
package/README.md
CHANGED
|
@@ -2,56 +2,353 @@
|
|
|
2
2
|
|
|
3
3
|
[Effector](https://effector.dev) bindings for
|
|
4
4
|
[react-hook-form](https://react-hook-form.com)'s headless `createFormControl`.
|
|
5
|
-
Form logic (fields, validation, submit) stays in react-hook-form; this package
|
|
6
|
-
lets Effector models read derived form state and drive the control with
|
|
7
|
-
effects, scope-safe.
|
|
8
5
|
|
|
9
6
|
```sh
|
|
10
7
|
pnpm add @reaxon/hook-form-effector effector react-hook-form
|
|
11
8
|
```
|
|
12
9
|
|
|
10
|
+
## How it thinks
|
|
11
|
+
|
|
12
|
+
**react-hook-form owns the form.** Fields, validation, dirty tracking,
|
|
13
|
+
submission: all of it stays in RHF, exactly as it would without Effector.
|
|
14
|
+
This package adds nothing on top of that and deliberately imposes almost no
|
|
15
|
+
structure of its own. It exists for two moments only:
|
|
16
|
+
|
|
17
|
+
1. **The model needs to read the form.** `formState({ form, setup })` mirrors
|
|
18
|
+
the control into a store, so you can derive other stores from it: a submit
|
|
19
|
+
button's `disabled`, a "you have unsaved changes" flag, an autosave trigger.
|
|
20
|
+
2. **The model needs to drive the form.** Reset it after a successful save,
|
|
21
|
+
prefill it when an edit dialog opens, push server validation errors back
|
|
22
|
+
onto the fields. Any RHF method is one `attach` away; a few factories cover
|
|
23
|
+
the calls that come up in every project, mostly so the payloads are typed.
|
|
24
|
+
|
|
25
|
+
The form control is a **mutable client object**. It is created once with
|
|
26
|
+
`createFormControl`, handed to the UI through `useForm({ formControl })`, and
|
|
27
|
+
handed to the model wrapped in a store. Where you create it is up to you: in
|
|
28
|
+
the model (the common case) or in the UI, passed down through a Gate. Every
|
|
29
|
+
export accepts either the bare control or a `Store` holding one, so the same
|
|
30
|
+
operators work in both layouts.
|
|
31
|
+
|
|
32
|
+
Prefer the store. A `$form` store is one line, and it is what makes the form
|
|
33
|
+
injectable: `fork({ values: [[$form, fakeControl]] })` swaps the control
|
|
34
|
+
under every effect and operator in a test, and a Provider can do the same per
|
|
35
|
+
scope in the app. Passing the bare control around works, but pins every
|
|
36
|
+
operator to that one instance.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
The model creates the form, mirrors it, and owns the save effect. The UI
|
|
41
|
+
renders it with plain RHF.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// profile.model.ts
|
|
45
|
+
import { combine, createEffect, createEvent, createStore, sample } from "effector";
|
|
46
|
+
import { createFormControl } from "react-hook-form";
|
|
47
|
+
import { formState, formResetApi } from "@reaxon/hook-form-effector";
|
|
48
|
+
|
|
49
|
+
type Profile = { name: string; email: string };
|
|
50
|
+
const empty: Profile = { name: "", email: "" };
|
|
51
|
+
|
|
52
|
+
// The control is a mutable client object: keep it out of serialization.
|
|
53
|
+
export const $form = createStore(
|
|
54
|
+
createFormControl<Profile>({ defaultValues: empty, mode: "onChange" }),
|
|
55
|
+
{ serialize: "ignore" },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const opened = createEvent();
|
|
59
|
+
export const closed = createEvent();
|
|
60
|
+
export const submitted = createEvent<Profile>();
|
|
61
|
+
|
|
62
|
+
export const $formState = formState({ form: $form, setup: opened, teardown: closed });
|
|
63
|
+
export const $canSubmit = combine($formState, (s) => !!s.isValid && !!s.isDirty);
|
|
64
|
+
|
|
65
|
+
export const saveProfileFx = createEffect(async (profile: Profile) => {
|
|
66
|
+
/* PUT /profile */
|
|
67
|
+
});
|
|
68
|
+
const resetFormFx = formResetApi($form, empty);
|
|
69
|
+
|
|
70
|
+
sample({ clock: submitted, target: saveProfileFx });
|
|
71
|
+
sample({ clock: saveProfileFx.done, target: resetFormFx });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
// ProfileForm.tsx
|
|
76
|
+
import { useEffect } from "react";
|
|
77
|
+
import { Controller, useForm } from "react-hook-form";
|
|
78
|
+
import { useUnit } from "effector-react";
|
|
79
|
+
import { $form, opened, closed, submitted, $canSubmit } from "./profile.model";
|
|
80
|
+
|
|
81
|
+
export function ProfileForm() {
|
|
82
|
+
const model = useUnit({
|
|
83
|
+
form: $form,
|
|
84
|
+
canSubmit: $canSubmit,
|
|
85
|
+
onOpen: opened,
|
|
86
|
+
onClose: closed,
|
|
87
|
+
onSubmit: submitted,
|
|
88
|
+
});
|
|
89
|
+
const form = useForm({ formControl: model.form.formControl });
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
model.onOpen();
|
|
93
|
+
return model.onClose;
|
|
94
|
+
}, [model.onOpen, model.onClose]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<form onSubmit={form.handleSubmit(model.onSubmit)}>
|
|
98
|
+
<Controller
|
|
99
|
+
control={form.control}
|
|
100
|
+
name="name"
|
|
101
|
+
rules={{ required: true }}
|
|
102
|
+
render={({ field, fieldState }) => (
|
|
103
|
+
<input {...field} aria-invalid={fieldState.invalid} />
|
|
104
|
+
)}
|
|
105
|
+
/>
|
|
106
|
+
<Controller
|
|
107
|
+
control={form.control}
|
|
108
|
+
name="email"
|
|
109
|
+
rules={{ required: true, pattern: /.+@.+/ }}
|
|
110
|
+
render={({ field, fieldState }) => (
|
|
111
|
+
<input {...field} type="email" aria-invalid={fieldState.invalid} />
|
|
112
|
+
)}
|
|
113
|
+
/>
|
|
114
|
+
<button disabled={!model.canSubmit}>Save</button>
|
|
115
|
+
</form>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Validation rules live on each `Controller`, submission goes through `handleSubmit`,
|
|
121
|
+
and the model never touches a DOM node. It only reads `$formState` and fires
|
|
122
|
+
one effect. The UI reads the control through `useUnit($form)` rather than
|
|
123
|
+
importing the instance, so under a scoped Provider it gets that scope's form.
|
|
124
|
+
|
|
13
125
|
## API
|
|
14
126
|
|
|
15
127
|
### `formState({ form, setup, teardown? })`
|
|
16
128
|
|
|
17
|
-
Mirrors a form control into
|
|
18
|
-
|
|
19
|
-
`
|
|
20
|
-
the
|
|
129
|
+
Mirrors a form control into `Store<{ values } & Partial<FormState>>`.
|
|
130
|
+
|
|
131
|
+
- `form`: a control from `createFormControl`, or a `Store` holding one.
|
|
132
|
+
- `setup`: an event that starts the subscription. Fire it under a scope, for
|
|
133
|
+
example on mount or on dialog open.
|
|
134
|
+
- `teardown`: an optional event that stops it.
|
|
135
|
+
|
|
136
|
+
The store carries `values` plus these RHF slices: `errors`, `isDirty`,
|
|
137
|
+
`isValid`, `isValidating`, `isSubmitted`, `touchedFields`, `dirtyFields`.
|
|
138
|
+
Everything except `values` is `undefined` until RHF's first notification, so
|
|
139
|
+
derive with defaults: `!!s.isValid`.
|
|
140
|
+
|
|
141
|
+
Two details worth knowing. The store is seeded with the current `values` on
|
|
142
|
+
attach, so a `reset` that ran in the same wave as `setup` is never missed. And
|
|
143
|
+
RHF notifies synchronously, sometimes during React render, so updates are
|
|
144
|
+
deferred by one microtask before reaching Effector; order is preserved.
|
|
145
|
+
|
|
146
|
+
### Effect factories
|
|
147
|
+
|
|
148
|
+
Each returns a single effect bound to the form. Each accepts a control or a
|
|
149
|
+
`Store` holding one.
|
|
150
|
+
|
|
151
|
+
| Factory | Effect params | What it does |
|
|
152
|
+
| --- | --- | --- |
|
|
153
|
+
| `formResetApi(form, emptyValues?)` | `void` | `form.reset(emptyValues)`, back to the pristine form |
|
|
154
|
+
| `formPrefillApi(form)` | `Values` | `form.reset(values)`, e.g. an edit prefill |
|
|
155
|
+
| `formSetApi(form)` | `{ name, value, options? }` | `form.setValue(name, value, options)` |
|
|
156
|
+
| `formSetErrorApi(form)` | `FormFieldError<Values>[]` | `form.setError(...)` for each entry |
|
|
157
|
+
|
|
158
|
+
Why `formResetApi` takes the empty values up front: RHF's `reset(values)`
|
|
159
|
+
**replaces** `defaultValues`. After a prefill, a bare `reset()` would restore
|
|
160
|
+
the prefilled record, not an empty form. Pinning the pristine values at
|
|
161
|
+
creation makes "reset" mean the same thing regardless of what came before.
|
|
162
|
+
|
|
163
|
+
The factories are conveniences, not the boundary of what you can do. See the
|
|
164
|
+
first recipe below.
|
|
165
|
+
|
|
166
|
+
### Types
|
|
167
|
+
|
|
168
|
+
- `FormControl<Values>`: the return type of `createFormControl<Values>`.
|
|
169
|
+
- `FormStateWithValues<Values>`: the state type of the `formState` store.
|
|
170
|
+
- `SetValuePayload<Values>`, `FormFieldError<Values>`: effect payloads.
|
|
171
|
+
|
|
172
|
+
## Recipes
|
|
173
|
+
|
|
174
|
+
### Call any form method from your own effect
|
|
175
|
+
|
|
176
|
+
There is no wrapper for `trigger`, `clearErrors`, `getFieldState` or
|
|
177
|
+
anything else, because none is needed. `attach` to the `$form` store.
|
|
21
178
|
|
|
22
179
|
```ts
|
|
23
|
-
import {
|
|
24
|
-
import { createFormControl } from 'react-hook-form';
|
|
25
|
-
import { formState } from '@reaxon/hook-form-effector';
|
|
180
|
+
import { attach } from "effector";
|
|
26
181
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
182
|
+
export const validateEmailFx = attach({
|
|
183
|
+
source: $form,
|
|
184
|
+
effect: (form) => form.trigger("email"),
|
|
185
|
+
});
|
|
30
186
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
187
|
+
export const clearErrorsFx = attach({
|
|
188
|
+
source: $form,
|
|
189
|
+
effect: (form, name?: "name" | "email") => form.clearErrors(name),
|
|
190
|
+
});
|
|
34
191
|
```
|
|
35
192
|
|
|
36
|
-
|
|
193
|
+
This is also the shape of every factory in this package, which is why they
|
|
194
|
+
take `$form` too: in a test, `fork({ values: [[$form, fakeControl]] })` swaps
|
|
195
|
+
the control under all of them at once.
|
|
196
|
+
|
|
197
|
+
### Derived stores: unsaved-changes guard and autosave
|
|
37
198
|
|
|
38
|
-
|
|
39
|
-
pristine values because RHF's `reset(values)` replaces `defaultValues`.
|
|
40
|
-
- `formPrefillApi(form)` — `form.reset(payload)`, e.g. an edit prefill.
|
|
41
|
-
- `formSetApi(form)` — `form.setValue(name, value, options)`.
|
|
42
|
-
- `formSetErrorApi(form)` — `form.setError(name, error, options)` for each
|
|
43
|
-
entry of a `FormFieldError[]` payload, e.g. to attach a server's field
|
|
44
|
-
errors after a failed submit.
|
|
199
|
+
`$formState` is an ordinary store. Map it, combine it, sample from it.
|
|
45
200
|
|
|
46
|
-
|
|
201
|
+
```ts
|
|
202
|
+
import { combine, createEffect, sample } from "effector";
|
|
203
|
+
import { debounce } from "patronum";
|
|
47
204
|
|
|
48
|
-
|
|
205
|
+
export const $hasUnsavedChanges = combine($formState, (s) => !!s.isDirty);
|
|
206
|
+
|
|
207
|
+
const autosaveFx = createEffect(async (draft: Profile) => {
|
|
208
|
+
/* PUT /profile/draft */
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
sample({
|
|
212
|
+
clock: debounce($formState, 800),
|
|
213
|
+
filter: (s) => !!s.isDirty && !!s.isValid,
|
|
214
|
+
fn: (s) => s.values,
|
|
215
|
+
target: autosaveFx,
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Edit dialog: prefill on open, reset on close
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { createEvent, sample } from "effector";
|
|
223
|
+
import { formPrefillApi, formResetApi } from "@reaxon/hook-form-effector";
|
|
224
|
+
|
|
225
|
+
export const editOpened = createEvent<Profile>();
|
|
226
|
+
export const editClosed = createEvent();
|
|
227
|
+
|
|
228
|
+
const prefillFx = formPrefillApi($form);
|
|
229
|
+
const resetFx = formResetApi($form, empty);
|
|
230
|
+
|
|
231
|
+
sample({ clock: editOpened, target: prefillFx });
|
|
232
|
+
sample({ clock: editClosed, target: resetFx });
|
|
233
|
+
|
|
234
|
+
export const $formState = formState({ form: $form, setup: editOpened, teardown: editClosed });
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`editOpened` both starts the mirror and prefills the form in one wave. The
|
|
238
|
+
attach snapshot inside `formState` guarantees the store starts with the
|
|
239
|
+
prefilled values, whichever runs first.
|
|
240
|
+
|
|
241
|
+
### Server validation errors back onto the fields
|
|
242
|
+
|
|
243
|
+
Map a failed save to field errors and let RHF render them next to the inputs.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { sample } from "effector";
|
|
247
|
+
import { formSetErrorApi, type FormFieldError } from "@reaxon/hook-form-effector";
|
|
248
|
+
|
|
249
|
+
const setErrorsFx = formSetErrorApi($form);
|
|
250
|
+
|
|
251
|
+
type ApiError = { fields?: Record<string, string[]> };
|
|
252
|
+
|
|
253
|
+
sample({
|
|
254
|
+
clock: saveProfileFx.failData,
|
|
255
|
+
fn: (error): FormFieldError<Profile>[] => {
|
|
256
|
+
const fields = (error as ApiError).fields ?? {};
|
|
257
|
+
return Object.entries(fields).map(([name, messages]) => ({
|
|
258
|
+
name: name as keyof Profile,
|
|
259
|
+
error: { type: "server", message: messages[0] },
|
|
260
|
+
}));
|
|
261
|
+
},
|
|
262
|
+
target: setErrorsFx,
|
|
263
|
+
});
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Names outside the form's fields are typed out at compile time, so a renamed
|
|
267
|
+
field breaks the mapping loudly instead of silently dropping an error. For a
|
|
268
|
+
message that belongs to the whole form use `name: "root"` or `"root.<key>"`
|
|
269
|
+
and read it from `formState.errors.root` in the UI.
|
|
270
|
+
|
|
271
|
+
### Dependent fields: set one field from the model
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { combine, sample } from "effector";
|
|
275
|
+
import { formSetApi } from "@reaxon/hook-form-effector";
|
|
276
|
+
|
|
277
|
+
const setFx = formSetApi($form);
|
|
278
|
+
const $country = combine($formState, (s) => s.values.country);
|
|
279
|
+
|
|
280
|
+
// When the user picks a country, snap the currency to that country's default.
|
|
281
|
+
sample({
|
|
282
|
+
clock: $country,
|
|
283
|
+
source: $currencyByCountry,
|
|
284
|
+
fn: (byCountry, country) => ({
|
|
285
|
+
name: "currency" as const,
|
|
286
|
+
value: byCountry[country],
|
|
287
|
+
options: { shouldDirty: true },
|
|
288
|
+
}),
|
|
289
|
+
target: setFx,
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Form created in the UI, handed to the model through a Gate
|
|
294
|
+
|
|
295
|
+
Sometimes the component owns the form, for instance when a third-party
|
|
296
|
+
wrapper insists on calling `useForm` itself. Pass the control up through a
|
|
297
|
+
Gate and let the model store it.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
// model.ts
|
|
301
|
+
import { combine, createStore, sample } from "effector";
|
|
302
|
+
import { createGate } from "effector-react";
|
|
303
|
+
import { formState, formResetApi } from "@reaxon/hook-form-effector";
|
|
304
|
+
import type { FormControl } from "@reaxon/hook-form-effector";
|
|
305
|
+
|
|
306
|
+
export const FormGate = createGate<{ form: FormControl<Profile> }>();
|
|
307
|
+
|
|
308
|
+
export const $form = createStore<FormControl<Profile> | null>(null, { serialize: "ignore" });
|
|
309
|
+
sample({ clock: FormGate.open, fn: ({ form }) => form, target: $form });
|
|
310
|
+
|
|
311
|
+
const $readyForm = combine($form, (form) => form!); // only read after Gate.open
|
|
312
|
+
export const $formState = formState({ form: $readyForm, setup: FormGate.open, teardown: FormGate.close });
|
|
313
|
+
export const resetFx = formResetApi($readyForm, empty);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
// Component.tsx
|
|
318
|
+
import { useMemo } from "react";
|
|
319
|
+
import { createFormControl, useForm } from "react-hook-form";
|
|
320
|
+
import { useGate } from "effector-react";
|
|
321
|
+
|
|
322
|
+
export function Component() {
|
|
323
|
+
const control = useMemo(() => createFormControl<Profile>({ defaultValues: empty }), []);
|
|
324
|
+
useGate(FormGate, { form: control });
|
|
325
|
+
const form = useForm({ formControl: control.formControl });
|
|
326
|
+
/* ... */
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`formState` re-subscribes whenever the store it was given changes, so a
|
|
331
|
+
remount that creates a fresh control is picked up automatically.
|
|
332
|
+
|
|
333
|
+
## Scopes and SSR
|
|
334
|
+
|
|
335
|
+
Everything here is scope-safe: the subscription callback is bound with
|
|
336
|
+
`scopeBind`, and all effects are `attach`ed, so `fork` and `allSettled` work as
|
|
337
|
+
expected. Two rules follow from the form being a mutable client object:
|
|
338
|
+
|
|
339
|
+
- Stores that hold a control are created with `serialize: "ignore"`. Do the
|
|
340
|
+
same for your own `$form` stores.
|
|
341
|
+
- A control created at module level is one object shared by every scope. For
|
|
342
|
+
SSR or per-request scopes, create it where the scope begins: in an effect
|
|
343
|
+
fired on open, or in the component and handed in through a Gate as above.
|
|
344
|
+
|
|
345
|
+
### Compiler plugin
|
|
49
346
|
|
|
50
347
|
Every export of this package is a factory: it creates stores, events and
|
|
51
348
|
effects on each call. For those units to get stable SIDs, which `fork` and
|
|
52
|
-
`serialize` need for
|
|
53
|
-
|
|
54
|
-
|
|
349
|
+
`serialize` need for hydration, list the package in the `factories` option of
|
|
350
|
+
the Effector compiler plugin. Client-only apps that never serialize a scope
|
|
351
|
+
can skip this.
|
|
55
352
|
|
|
56
353
|
Babel (`effector/babel-plugin`):
|
|
57
354
|
|
|
@@ -84,7 +381,12 @@ react({
|
|
|
84
381
|
## Peer dependencies
|
|
85
382
|
|
|
86
383
|
- `effector` `^23`
|
|
87
|
-
- `react-hook-form` `^7.72` (`createFormControl` exists since 7.55, but the
|
|
384
|
+
- `react-hook-form` `^7.72` (`createFormControl` exists since 7.55, but the
|
|
385
|
+
`root.*` error names and the `isSubmitted` subscription flag this package
|
|
386
|
+
relies on typecheck from 7.72.0)
|
|
387
|
+
|
|
388
|
+
`effector-react` and `patronum` appear in the recipes but are not required by
|
|
389
|
+
the package itself.
|
|
88
390
|
|
|
89
391
|
## License
|
|
90
392
|
|
package/package.json
CHANGED