@fogpipe/forma-react 0.13.0 → 0.14.0

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/README.md CHANGED
@@ -193,6 +193,69 @@ function WizardForm({ spec }: { spec: Forma }) {
193
193
  }
194
194
  ```
195
195
 
196
+ ## Event System
197
+
198
+ The event system lets you observe form lifecycle events for side effects like analytics, data injection, and external state sync. Events do not trigger React re-renders.
199
+
200
+ ### Available Events
201
+
202
+ | Event | Description |
203
+ | -------------- | ------------------------------------------------------- |
204
+ | `fieldChanged` | Fires after a field value changes |
205
+ | `preSubmit` | Fires before validation; `data` is mutable |
206
+ | `postSubmit` | Fires after submission (success, error, or invalid) |
207
+ | `pageChanged` | Fires when wizard page changes |
208
+ | `formReset` | Fires after `resetForm()` completes |
209
+
210
+ ### Declarative Registration
211
+
212
+ Pass listeners via the `on` option — callbacks are stable (ref-based):
213
+
214
+ ```tsx
215
+ const forma = useForma({
216
+ spec,
217
+ onSubmit: handleSubmit,
218
+ on: {
219
+ fieldChanged: (event) => {
220
+ analytics.track("field_changed", { path: event.path, source: event.source });
221
+ },
222
+ preSubmit: async (event) => {
223
+ event.data.token = await getCSRFToken();
224
+ },
225
+ postSubmit: (event) => {
226
+ if (event.success) router.push("/thank-you");
227
+ },
228
+ },
229
+ });
230
+ ```
231
+
232
+ ### Imperative Registration
233
+
234
+ Register listeners dynamically — returns an unsubscribe function:
235
+
236
+ ```tsx
237
+ const forma = useForma({ spec, onSubmit: handleSubmit });
238
+
239
+ useEffect(() => {
240
+ const unsubscribe = forma.on("fieldChanged", (event) => {
241
+ console.log(`${event.path}: ${event.previousValue} → ${event.value}`);
242
+ });
243
+ return unsubscribe;
244
+ }, [forma]);
245
+ ```
246
+
247
+ ### Event Payloads
248
+
249
+ **`fieldChanged`**: `{ path, value, previousValue, source }` where `source` is `'user' | 'reset' | 'setValues'`
250
+
251
+ **`preSubmit`**: `{ data, computed }` — mutate `data` to inject fields before validation
252
+
253
+ **`postSubmit`**: `{ data, success, error?, validationErrors? }`
254
+
255
+ **`pageChanged`**: `{ fromIndex, toIndex, page }`
256
+
257
+ **`formReset`**: `{}` (no payload)
258
+
196
259
  ## API Reference
197
260
 
198
261
  ### FormRenderer Props
@@ -239,15 +302,16 @@ formRef.current?.setValues({ name: "John" });
239
302
 
240
303
  ### useForma Methods
241
304
 
242
- | Method | Description |
243
- | --------------------------------- | ----------------------- |
244
- | `setFieldValue(path, value)` | Set field value |
245
- | `setFieldTouched(path, touched?)` | Mark field as touched |
246
- | `setValues(values)` | Set multiple values |
247
- | `validateField(path)` | Validate single field |
248
- | `validateForm()` | Validate entire form |
249
- | `submitForm()` | Submit the form |
250
- | `resetForm()` | Reset to initial values |
305
+ | Method | Description |
306
+ | --------------------------------- | ------------------------------------ |
307
+ | `setFieldValue(path, value)` | Set field value |
308
+ | `setFieldTouched(path, touched?)` | Mark field as touched |
309
+ | `setValues(values)` | Set multiple values |
310
+ | `validateField(path)` | Validate single field |
311
+ | `validateForm()` | Validate entire form |
312
+ | `submitForm()` | Submit the form |
313
+ | `resetForm()` | Reset to initial values |
314
+ | `on(event, listener)` | Register event listener; returns unsubscribe |
251
315
 
252
316
  ### useForma Options
253
317
 
@@ -260,6 +324,7 @@ formRef.current?.setValues({ name: "John" });
260
324
  | `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate |
261
325
  | `referenceData` | `Record<string, unknown>` | - | Additional reference data |
262
326
  | `validationDebounceMs` | `number` | `0` | Debounce validation (ms) |
327
+ | `on` | `FormaEvents` | - | Declarative event listeners |
263
328
 
264
329
  ## Error Boundary
265
330
 
package/dist/index.d.ts CHANGED
@@ -4,6 +4,84 @@ import * as React$1 from 'react';
4
4
  import React__default from 'react';
5
5
  import * as react_jsx_runtime from 'react/jsx-runtime';
6
6
 
7
+ /**
8
+ * Event system for forma-react
9
+ *
10
+ * Lightweight event emitter for form lifecycle events.
11
+ * Events are for side effects (analytics, data injection, external state sync)
12
+ * — they do not trigger React re-renders.
13
+ */
14
+
15
+ /**
16
+ * Map of all forma event names to their payload types.
17
+ */
18
+ interface FormaEventMap {
19
+ /**
20
+ * Fires after a field value changes via user input, setFieldValue, setValues, or resetForm.
21
+ * Does NOT fire for computed/calculated field changes or on initial mount.
22
+ */
23
+ fieldChanged: {
24
+ /** Field path (e.g., "age" or "medications[0].dosage") */
25
+ path: string;
26
+ /** New value */
27
+ value: unknown;
28
+ /** Value before the change */
29
+ previousValue: unknown;
30
+ /** What triggered the change */
31
+ source: "user" | "reset" | "setValues";
32
+ };
33
+ /**
34
+ * Fires at the start of submitForm(), before validation.
35
+ * The `data` object is mutable — consumers can inject extra fields.
36
+ * Async handlers are awaited before proceeding to validation.
37
+ */
38
+ preSubmit: {
39
+ /** Mutable form data — add/modify fields here */
40
+ data: Record<string, unknown>;
41
+ /** Read-only snapshot of computed values */
42
+ computed: Record<string, unknown>;
43
+ };
44
+ /**
45
+ * Fires after onSubmit resolves/rejects or after validation failure.
46
+ */
47
+ postSubmit: {
48
+ /** The submitted data (reflects any preSubmit mutations) */
49
+ data: Record<string, unknown>;
50
+ /** Whether submission succeeded */
51
+ success: boolean;
52
+ /** Present when onSubmit threw an error */
53
+ error?: Error;
54
+ /** Present when validation failed (onSubmit was never called) */
55
+ validationErrors?: FieldError[];
56
+ };
57
+ /**
58
+ * Fires when the wizard page changes via nextPage, previousPage, or goToPage.
59
+ * Does NOT fire on initial render or automatic page clamping.
60
+ */
61
+ pageChanged: {
62
+ /** Previous page index */
63
+ fromIndex: number;
64
+ /** New page index */
65
+ toIndex: number;
66
+ /** The new current page */
67
+ page: PageState;
68
+ };
69
+ /**
70
+ * Fires after resetForm() completes and state is back to initial values.
71
+ */
72
+ formReset: Record<string, never>;
73
+ }
74
+ /**
75
+ * Listener function type for a specific event.
76
+ */
77
+ type FormaEventListener<K extends keyof FormaEventMap> = (event: FormaEventMap[K]) => void | Promise<void>;
78
+ /**
79
+ * Declarative event listener map for useForma `on` option.
80
+ */
81
+ type FormaEvents = Partial<{
82
+ [K in keyof FormaEventMap]: FormaEventListener<K>;
83
+ }>;
84
+
7
85
  /**
8
86
  * Type definitions for forma-react components
9
87
  */
@@ -499,6 +577,12 @@ interface UseFormaOptions {
499
577
  * Set to 0 (default) for immediate validation.
500
578
  */
501
579
  validationDebounceMs?: number;
580
+ /**
581
+ * Declarative event listeners for form lifecycle events.
582
+ * Listeners are stable for the lifetime of the hook — the latest
583
+ * callback is always invoked via refs, without causing dependency changes.
584
+ */
585
+ on?: FormaEvents;
502
586
  }
503
587
  /**
504
588
  * Page state for multi-page forms
@@ -575,6 +659,11 @@ interface UseFormaReturn {
575
659
  submitForm: () => Promise<void>;
576
660
  /** Reset the form */
577
661
  resetForm: () => void;
662
+ /**
663
+ * Register an imperative event listener. Returns an unsubscribe function.
664
+ * Multiple listeners per event are supported; they fire in registration order.
665
+ */
666
+ on: <K extends keyof FormaEventMap>(event: K, listener: (payload: FormaEventMap[K]) => void | Promise<void>) => () => void;
578
667
  /** Get props for any field */
579
668
  getFieldProps: (path: string) => GetFieldPropsResult;
580
669
  /** Get props for select field (includes options) */
@@ -722,4 +811,4 @@ declare const FormaContext: React$1.Context<UseFormaReturn | null>;
722
811
  */
723
812
  declare function useFormaContext(): UseFormaReturn;
724
813
 
725
- export { type ArrayComponentProps, type ArrayFieldProps, type ArrayHelpers, type ArrayItemFieldProps, type ArrayItemFieldPropsResult, type BaseFieldProps, type BooleanComponentProps, type BooleanFieldProps, type ComponentMap, type ComputedComponentProps, type ComputedFieldProps, type DateComponentProps, type DateFieldProps, type DateTimeComponentProps, type DateTimeFieldProps, type DisplayComponentProps, type DisplayFieldProps, type FieldComponentProps, type FieldProps, FieldRenderer, type FieldRendererProps, type FieldWrapperProps, FormRenderer, type FormRendererHandle, type FormRendererProps, type UseFormaReturn as FormState, FormaContext, FormaErrorBoundary, type FormaErrorBoundaryProps, type GetArrayHelpersResult, type GetFieldPropsResult, type GetSelectFieldPropsResult, type IntegerComponentProps, type IntegerFieldProps, type LayoutProps, type LegacyFieldProps, type MultiSelectComponentProps, type MultiSelectFieldProps, type NumberComponentProps, type NumberFieldProps, type ObjectComponentProps, type ObjectFieldProps, type PageState, type PageWrapperProps, type SelectComponentProps, type SelectFieldProps, type SelectionFieldProps, type TextComponentProps, type TextFieldProps, type UseFormaOptions, type UseFormaReturn, type WizardHelpers, useForma, useFormaContext };
814
+ export { type ArrayComponentProps, type ArrayFieldProps, type ArrayHelpers, type ArrayItemFieldProps, type ArrayItemFieldPropsResult, type BaseFieldProps, type BooleanComponentProps, type BooleanFieldProps, type ComponentMap, type ComputedComponentProps, type ComputedFieldProps, type DateComponentProps, type DateFieldProps, type DateTimeComponentProps, type DateTimeFieldProps, type DisplayComponentProps, type DisplayFieldProps, type FieldComponentProps, type FieldProps, FieldRenderer, type FieldRendererProps, type FieldWrapperProps, FormRenderer, type FormRendererHandle, type FormRendererProps, type UseFormaReturn as FormState, FormaContext, FormaErrorBoundary, type FormaErrorBoundaryProps, type FormaEventListener, type FormaEventMap, type FormaEvents, type GetArrayHelpersResult, type GetFieldPropsResult, type GetSelectFieldPropsResult, type IntegerComponentProps, type IntegerFieldProps, type LayoutProps, type LegacyFieldProps, type MultiSelectComponentProps, type MultiSelectFieldProps, type NumberComponentProps, type NumberFieldProps, type ObjectComponentProps, type ObjectFieldProps, type PageState, type PageWrapperProps, type SelectComponentProps, type SelectFieldProps, type SelectionFieldProps, type TextComponentProps, type TextFieldProps, type UseFormaOptions, type UseFormaReturn, type WizardHelpers, useForma, useFormaContext };
package/dist/index.js CHANGED
@@ -8,6 +8,74 @@ import {
8
8
  useState
9
9
  } from "react";
10
10
  import { isAdornableField } from "@fogpipe/forma-core";
11
+
12
+ // src/events.ts
13
+ var FormaEventEmitter = class {
14
+ listeners = /* @__PURE__ */ new Map();
15
+ /**
16
+ * Register a listener for an event. Returns an unsubscribe function.
17
+ */
18
+ on(event, listener) {
19
+ if (!this.listeners.has(event)) {
20
+ this.listeners.set(event, /* @__PURE__ */ new Set());
21
+ }
22
+ this.listeners.get(event).add(listener);
23
+ return () => {
24
+ const set = this.listeners.get(event);
25
+ if (set) {
26
+ set.delete(listener);
27
+ if (set.size === 0) {
28
+ this.listeners.delete(event);
29
+ }
30
+ }
31
+ };
32
+ }
33
+ /**
34
+ * Fire an event synchronously. Listener errors are caught and logged
35
+ * to prevent one listener from breaking others.
36
+ */
37
+ fire(event, payload) {
38
+ const set = this.listeners.get(event);
39
+ if (!set || set.size === 0) return;
40
+ for (const listener of set) {
41
+ try {
42
+ listener(payload);
43
+ } catch (error) {
44
+ console.error(`[forma] Error in "${event}" event listener:`, error);
45
+ }
46
+ }
47
+ }
48
+ /**
49
+ * Fire an event and await all async listeners sequentially.
50
+ * Used for preSubmit where handlers can be async.
51
+ */
52
+ async fireAsync(event, payload) {
53
+ const set = this.listeners.get(event);
54
+ if (!set || set.size === 0) return;
55
+ for (const listener of set) {
56
+ try {
57
+ await listener(payload);
58
+ } catch (error) {
59
+ console.error(`[forma] Error in "${event}" event listener:`, error);
60
+ }
61
+ }
62
+ }
63
+ /**
64
+ * Check if any listeners are registered for an event.
65
+ */
66
+ hasListeners(event) {
67
+ const set = this.listeners.get(event);
68
+ return set !== void 0 && set.size > 0;
69
+ }
70
+ /**
71
+ * Remove all listeners. Called on cleanup.
72
+ */
73
+ clear() {
74
+ this.listeners.clear();
75
+ }
76
+ };
77
+
78
+ // src/useForma.ts
11
79
  import {
12
80
  getVisibility,
13
81
  getRequired,
@@ -89,7 +157,8 @@ function useForma(options) {
89
157
  onChange,
90
158
  validateOn = "blur",
91
159
  referenceData,
92
- validationDebounceMs = 0
160
+ validationDebounceMs = 0,
161
+ on: onEvents
93
162
  } = options;
94
163
  const spec = useMemo(() => {
95
164
  if (!referenceData) return inputSpec;
@@ -116,6 +185,30 @@ function useForma(options) {
116
185
  const stateDataRef = useRef(state.data);
117
186
  stateDataRef.current = state.data;
118
187
  const hasInitialized = useRef(false);
188
+ const emitterRef = useRef(new FormaEventEmitter());
189
+ const onEventsRef = useRef(onEvents);
190
+ onEventsRef.current = onEvents;
191
+ const pendingEventsRef = useRef([]);
192
+ const isFiringEventsRef = useRef(false);
193
+ useEffect(() => {
194
+ const emitter = emitterRef.current;
195
+ return () => {
196
+ emitter.clear();
197
+ };
198
+ }, []);
199
+ const fireEvent = useCallback(
200
+ (event, payload) => {
201
+ var _a;
202
+ try {
203
+ const handler = (_a = onEventsRef.current) == null ? void 0 : _a[event];
204
+ if (handler) handler(payload);
205
+ } catch (error) {
206
+ console.error(`[forma] Error in "${event}" event handler:`, error);
207
+ }
208
+ emitterRef.current.fire(event, payload);
209
+ },
210
+ []
211
+ );
119
212
  const computed = useMemo(
120
213
  () => calculate(state.data, spec),
121
214
  [state.data, spec]
@@ -196,21 +289,49 @@ function useForma(options) {
196
289
  },
197
290
  [state.data]
198
291
  );
292
+ const getValueAtPath = useCallback((path) => {
293
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
294
+ let value = stateDataRef.current;
295
+ for (const part of parts) {
296
+ if (value === null || value === void 0) return void 0;
297
+ value = value[part];
298
+ }
299
+ return value;
300
+ }, []);
301
+ const queueFieldChangedEvent = useCallback(
302
+ (path, value, source) => {
303
+ if (isFiringEventsRef.current) return;
304
+ const previousValue = getValueAtPath(path);
305
+ if (previousValue === value) return;
306
+ pendingEventsRef.current.push({
307
+ event: "fieldChanged",
308
+ payload: { path, value, previousValue, source }
309
+ });
310
+ },
311
+ [getValueAtPath]
312
+ );
199
313
  const setFieldValue = useCallback(
200
314
  (path, value) => {
315
+ queueFieldChangedEvent(path, value, "user");
201
316
  setNestedValue(path, value);
202
317
  if (validateOn === "change") {
203
318
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
204
319
  }
205
320
  },
206
- [validateOn, setNestedValue]
321
+ [validateOn, setNestedValue, queueFieldChangedEvent]
207
322
  );
208
323
  const setFieldTouched = useCallback((path, touched = true) => {
209
324
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched });
210
325
  }, []);
211
- const setValues = useCallback((values) => {
212
- dispatch({ type: "SET_VALUES", values });
213
- }, []);
326
+ const setValues = useCallback(
327
+ (values) => {
328
+ for (const [key, value] of Object.entries(values)) {
329
+ queueFieldChangedEvent(key, value, "setValues");
330
+ }
331
+ dispatch({ type: "SET_VALUES", values });
332
+ },
333
+ [queueFieldChangedEvent]
334
+ );
214
335
  const validateField = useCallback(
215
336
  (path) => {
216
337
  return validation.errors.filter((e) => e.field === path);
@@ -221,25 +342,83 @@ function useForma(options) {
221
342
  return validation;
222
343
  }, [validation]);
223
344
  const submitForm = useCallback(async () => {
345
+ var _a;
224
346
  dispatch({ type: "SET_SUBMITTING", isSubmitting: true });
347
+ const submissionData = { ...state.data };
348
+ let postSubmitPayload;
225
349
  try {
226
- if (immediateValidation.valid && onSubmit) {
227
- await onSubmit(state.data);
350
+ const preSubmitPayload = {
351
+ data: submissionData,
352
+ computed: { ...computed }
353
+ };
354
+ const declarativePreSubmit = (_a = onEventsRef.current) == null ? void 0 : _a.preSubmit;
355
+ if (declarativePreSubmit) {
356
+ await declarativePreSubmit(preSubmitPayload);
357
+ }
358
+ if (emitterRef.current.hasListeners("preSubmit")) {
359
+ await emitterRef.current.fireAsync("preSubmit", preSubmitPayload);
360
+ }
361
+ if (!immediateValidation.valid) {
362
+ postSubmitPayload = {
363
+ data: submissionData,
364
+ success: false,
365
+ validationErrors: immediateValidation.errors
366
+ };
367
+ } else if (onSubmit) {
368
+ try {
369
+ await onSubmit(submissionData);
370
+ postSubmitPayload = { data: submissionData, success: true };
371
+ } catch (error) {
372
+ postSubmitPayload = {
373
+ data: submissionData,
374
+ success: false,
375
+ error: error instanceof Error ? error : new Error(String(error))
376
+ };
377
+ }
378
+ } else {
379
+ postSubmitPayload = { data: submissionData, success: true };
228
380
  }
229
381
  dispatch({ type: "SET_SUBMITTED", isSubmitted: true });
230
382
  } finally {
231
383
  dispatch({ type: "SET_SUBMITTING", isSubmitting: false });
384
+ if (postSubmitPayload) {
385
+ fireEvent("postSubmit", postSubmitPayload);
386
+ }
232
387
  }
233
- }, [immediateValidation, onSubmit, state.data]);
388
+ }, [immediateValidation, onSubmit, state.data, computed, fireEvent]);
234
389
  const resetForm = useCallback(() => {
235
- dispatch({
236
- type: "RESET",
237
- initialData: {
238
- ...getDefaultBooleanValues(spec),
239
- ...getFieldDefaults(spec),
240
- ...initialData
390
+ const resetData = {
391
+ ...getDefaultBooleanValues(spec),
392
+ ...getFieldDefaults(spec),
393
+ ...initialData
394
+ };
395
+ if (!isFiringEventsRef.current) {
396
+ const currentData = stateDataRef.current;
397
+ const allKeys = /* @__PURE__ */ new Set([
398
+ ...Object.keys(currentData),
399
+ ...Object.keys(resetData)
400
+ ]);
401
+ for (const key of allKeys) {
402
+ const currentVal = currentData[key];
403
+ const resetVal = resetData[key];
404
+ if (currentVal !== resetVal) {
405
+ pendingEventsRef.current.push({
406
+ event: "fieldChanged",
407
+ payload: {
408
+ path: key,
409
+ value: resetVal,
410
+ previousValue: currentVal,
411
+ source: "reset"
412
+ }
413
+ });
414
+ }
241
415
  }
242
- });
416
+ pendingEventsRef.current.push({
417
+ event: "formReset",
418
+ payload: {}
419
+ });
420
+ }
421
+ dispatch({ type: "RESET", initialData: resetData });
243
422
  }, [spec, initialData]);
244
423
  const wizard = useMemo(() => {
245
424
  if (!spec.pages || spec.pages.length === 0) return null;
@@ -270,16 +449,44 @@ function useForma(options) {
270
449
  currentPage,
271
450
  goToPage: (index) => {
272
451
  const validIndex = Math.min(Math.max(0, index), maxPageIndex);
273
- dispatch({ type: "SET_PAGE", page: validIndex });
452
+ if (validIndex !== clampedPageIndex) {
453
+ dispatch({ type: "SET_PAGE", page: validIndex });
454
+ const newPage = visiblePages[validIndex];
455
+ if (newPage) {
456
+ fireEvent("pageChanged", {
457
+ fromIndex: clampedPageIndex,
458
+ toIndex: validIndex,
459
+ page: newPage
460
+ });
461
+ }
462
+ }
274
463
  },
275
464
  nextPage: () => {
276
465
  if (hasNextPage) {
277
- dispatch({ type: "SET_PAGE", page: clampedPageIndex + 1 });
466
+ const toIndex = clampedPageIndex + 1;
467
+ dispatch({ type: "SET_PAGE", page: toIndex });
468
+ const newPage = visiblePages[toIndex];
469
+ if (newPage) {
470
+ fireEvent("pageChanged", {
471
+ fromIndex: clampedPageIndex,
472
+ toIndex,
473
+ page: newPage
474
+ });
475
+ }
278
476
  }
279
477
  },
280
478
  previousPage: () => {
281
479
  if (hasPreviousPage) {
282
- dispatch({ type: "SET_PAGE", page: clampedPageIndex - 1 });
480
+ const toIndex = clampedPageIndex - 1;
481
+ dispatch({ type: "SET_PAGE", page: toIndex });
482
+ const newPage = visiblePages[toIndex];
483
+ if (newPage) {
484
+ fireEvent("pageChanged", {
485
+ fromIndex: clampedPageIndex,
486
+ toIndex,
487
+ page: newPage
488
+ });
489
+ }
283
490
  }
284
491
  },
285
492
  hasNextPage,
@@ -310,40 +517,51 @@ function useForma(options) {
310
517
  return pageErrors.length === 0;
311
518
  }
312
519
  };
313
- }, [spec, state.data, state.currentPage, computed, validation, visibility]);
314
- const getValueAtPath = useCallback((path) => {
315
- const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
316
- let value = stateDataRef.current;
317
- for (const part of parts) {
318
- if (value === null || value === void 0) return void 0;
319
- value = value[part];
320
- }
321
- return value;
322
- }, []);
323
- const setValueAtPath = useCallback((path, value) => {
324
- const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
325
- if (parts.length === 1) {
326
- dispatch({ type: "SET_FIELD_VALUE", field: path, value });
327
- return;
328
- }
329
- const newData = { ...stateDataRef.current };
330
- let current = newData;
331
- for (let i = 0; i < parts.length - 1; i++) {
332
- const part = parts[i];
333
- const nextPart = parts[i + 1];
334
- const isNextArrayIndex = /^\d+$/.test(nextPart);
335
- if (current[part] === void 0) {
336
- current[part] = isNextArrayIndex ? [] : {};
337
- } else if (Array.isArray(current[part])) {
338
- current[part] = [...current[part]];
339
- } else {
340
- current[part] = { ...current[part] };
520
+ }, [spec, state.data, state.currentPage, computed, validation, visibility, fireEvent]);
521
+ useEffect(() => {
522
+ const events = pendingEventsRef.current;
523
+ if (events.length === 0) return;
524
+ pendingEventsRef.current = [];
525
+ isFiringEventsRef.current = true;
526
+ try {
527
+ for (const pending of events) {
528
+ fireEvent(
529
+ pending.event,
530
+ pending.payload
531
+ );
341
532
  }
342
- current = current[part];
533
+ } finally {
534
+ isFiringEventsRef.current = false;
343
535
  }
344
- current[parts[parts.length - 1]] = value;
345
- dispatch({ type: "SET_VALUES", values: newData });
346
- }, []);
536
+ });
537
+ const setValueAtPath = useCallback(
538
+ (path, value) => {
539
+ queueFieldChangedEvent(path, value, "user");
540
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
541
+ if (parts.length === 1) {
542
+ dispatch({ type: "SET_FIELD_VALUE", field: path, value });
543
+ return;
544
+ }
545
+ const newData = { ...stateDataRef.current };
546
+ let current = newData;
547
+ for (let i = 0; i < parts.length - 1; i++) {
548
+ const part = parts[i];
549
+ const nextPart = parts[i + 1];
550
+ const isNextArrayIndex = /^\d+$/.test(nextPart);
551
+ if (current[part] === void 0) {
552
+ current[part] = isNextArrayIndex ? [] : {};
553
+ } else if (Array.isArray(current[part])) {
554
+ current[part] = [...current[part]];
555
+ } else {
556
+ current[part] = { ...current[part] };
557
+ }
558
+ current = current[part];
559
+ }
560
+ current[parts[parts.length - 1]] = value;
561
+ dispatch({ type: "SET_VALUES", values: newData });
562
+ },
563
+ [queueFieldChangedEvent]
564
+ );
347
565
  const fieldHandlers = useRef(/* @__PURE__ */ new Map());
348
566
  useEffect(() => {
349
567
  const validFields = new Set(spec.fieldOrder);
@@ -563,6 +781,10 @@ function useForma(options) {
563
781
  optionsVisibility
564
782
  ]
565
783
  );
784
+ const on = useCallback(
785
+ (event, listener) => emitterRef.current.on(event, listener),
786
+ []
787
+ );
566
788
  return useMemo(
567
789
  () => ({
568
790
  data: state.data,
@@ -587,6 +809,7 @@ function useForma(options) {
587
809
  validateForm,
588
810
  submitForm,
589
811
  resetForm,
812
+ on,
590
813
  getFieldProps,
591
814
  getSelectFieldProps,
592
815
  getArrayHelpers
@@ -614,6 +837,7 @@ function useForma(options) {
614
837
  validateForm,
615
838
  submitForm,
616
839
  resetForm,
840
+ on,
617
841
  getFieldProps,
618
842
  getSelectFieldProps,
619
843
  getArrayHelpers