@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +332 -30
  3. 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 a store of `{ values, ...FormState }`. `setup`
18
- starts the subscription (fire it under a scope, e.g. on dialog open);
19
- `teardown` stops it. The store is seeded on attach, so a `reset` that ran in
20
- the same wave as `setup` is never missed.
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 { createEvent, fork, allSettled } from 'effector';
24
- import { createFormControl } from 'react-hook-form';
25
- import { formState } from '@reaxon/hook-form-effector';
180
+ import { attach } from "effector";
26
181
 
27
- const form = createFormControl<{ name: string }>({ defaultValues: { name: '' } });
28
- const opened = createEvent();
29
- const $form = formState({ form, setup: opened });
182
+ export const validateEmailFx = attach({
183
+ source: $form,
184
+ effect: (form) => form.trigger("email"),
185
+ });
30
186
 
31
- const scope = fork();
32
- await allSettled(opened, { scope });
33
- scope.getState($form).values.name; // ''
187
+ export const clearErrorsFx = attach({
188
+ source: $form,
189
+ effect: (form, name?: "name" | "email") => form.clearErrors(name),
190
+ });
34
191
  ```
35
192
 
36
- ### Effect factories over the control
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
- - `formResetApi(form, emptyValues)` `form.reset(emptyValues)`; pins the
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
- Each accepts either a form control or a `Store` holding one.
201
+ ```ts
202
+ import { combine, createEffect, sample } from "effector";
203
+ import { debounce } from "patronum";
47
204
 
48
- ## Effector plugin setup (SSR / `fork`)
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 SSR and for hydration, list the package in the
53
- `factories` option of the Effector compiler plugin. Client-only apps that
54
- never serialize a scope can skip this.
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 `form.*` error names and the `isSubmitted` subscription flag this package relies on typecheck from 7.72.0)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reaxon/hook-form-effector",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Effector bindings for react-hook-form: mirror form state into stores and drive the form control with effects",
5
5
  "keywords": [
6
6
  "effector",