@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/src/events.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Event system for forma-react
3
+ *
4
+ * Lightweight event emitter for form lifecycle events.
5
+ * Events are for side effects (analytics, data injection, external state sync)
6
+ * — they do not trigger React re-renders.
7
+ */
8
+
9
+ import type { FieldError } from "@fogpipe/forma-core";
10
+ import type { PageState } from "./useForma.js";
11
+
12
+ // ============================================================================
13
+ // Event Type Definitions
14
+ // ============================================================================
15
+
16
+ /**
17
+ * Map of all forma event names to their payload types.
18
+ */
19
+ export interface FormaEventMap {
20
+ /**
21
+ * Fires after a field value changes via user input, setFieldValue, setValues, or resetForm.
22
+ * Does NOT fire for computed/calculated field changes or on initial mount.
23
+ */
24
+ fieldChanged: {
25
+ /** Field path (e.g., "age" or "medications[0].dosage") */
26
+ path: string;
27
+ /** New value */
28
+ value: unknown;
29
+ /** Value before the change */
30
+ previousValue: unknown;
31
+ /** What triggered the change */
32
+ source: "user" | "reset" | "setValues";
33
+ };
34
+
35
+ /**
36
+ * Fires at the start of submitForm(), before validation.
37
+ * The `data` object is mutable — consumers can inject extra fields.
38
+ * Async handlers are awaited before proceeding to validation.
39
+ */
40
+ preSubmit: {
41
+ /** Mutable form data — add/modify fields here */
42
+ data: Record<string, unknown>;
43
+ /** Read-only snapshot of computed values */
44
+ computed: Record<string, unknown>;
45
+ };
46
+
47
+ /**
48
+ * Fires after onSubmit resolves/rejects or after validation failure.
49
+ */
50
+ postSubmit: {
51
+ /** The submitted data (reflects any preSubmit mutations) */
52
+ data: Record<string, unknown>;
53
+ /** Whether submission succeeded */
54
+ success: boolean;
55
+ /** Present when onSubmit threw an error */
56
+ error?: Error;
57
+ /** Present when validation failed (onSubmit was never called) */
58
+ validationErrors?: FieldError[];
59
+ };
60
+
61
+ /**
62
+ * Fires when the wizard page changes via nextPage, previousPage, or goToPage.
63
+ * Does NOT fire on initial render or automatic page clamping.
64
+ */
65
+ pageChanged: {
66
+ /** Previous page index */
67
+ fromIndex: number;
68
+ /** New page index */
69
+ toIndex: number;
70
+ /** The new current page */
71
+ page: PageState;
72
+ };
73
+
74
+ /**
75
+ * Fires after resetForm() completes and state is back to initial values.
76
+ */
77
+ formReset: Record<string, never>;
78
+ }
79
+
80
+ // ============================================================================
81
+ // Helper Types
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Listener function type for a specific event.
86
+ */
87
+ export type FormaEventListener<K extends keyof FormaEventMap> = (
88
+ event: FormaEventMap[K],
89
+ ) => void | Promise<void>;
90
+
91
+ /**
92
+ * Declarative event listener map for useForma `on` option.
93
+ */
94
+ export type FormaEvents = Partial<{
95
+ [K in keyof FormaEventMap]: FormaEventListener<K>;
96
+ }>;
97
+
98
+ // ============================================================================
99
+ // Event Emitter
100
+ // ============================================================================
101
+
102
+ /**
103
+ * Lightweight event emitter for forma lifecycle events.
104
+ * Uses Map<string, Set<listener>> internally — no external dependencies.
105
+ */
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ type AnyListener = (...args: any[]) => void | Promise<void>;
108
+
109
+ export class FormaEventEmitter {
110
+ private listeners = new Map<string, Set<AnyListener>>();
111
+
112
+ /**
113
+ * Register a listener for an event. Returns an unsubscribe function.
114
+ */
115
+ on<K extends keyof FormaEventMap>(
116
+ event: K,
117
+ listener: FormaEventListener<K>,
118
+ ): () => void {
119
+ if (!this.listeners.has(event)) {
120
+ this.listeners.set(event, new Set());
121
+ }
122
+ this.listeners.get(event)!.add(listener);
123
+
124
+ return () => {
125
+ const set = this.listeners.get(event);
126
+ if (set) {
127
+ set.delete(listener);
128
+ if (set.size === 0) {
129
+ this.listeners.delete(event);
130
+ }
131
+ }
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Fire an event synchronously. Listener errors are caught and logged
137
+ * to prevent one listener from breaking others.
138
+ */
139
+ fire<K extends keyof FormaEventMap>(event: K, payload: FormaEventMap[K]): void {
140
+ const set = this.listeners.get(event);
141
+ if (!set || set.size === 0) return;
142
+
143
+ for (const listener of set) {
144
+ try {
145
+ listener(payload);
146
+ } catch (error) {
147
+ console.error(`[forma] Error in "${event}" event listener:`, error);
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Fire an event and await all async listeners sequentially.
154
+ * Used for preSubmit where handlers can be async.
155
+ */
156
+ async fireAsync<K extends keyof FormaEventMap>(
157
+ event: K,
158
+ payload: FormaEventMap[K],
159
+ ): Promise<void> {
160
+ const set = this.listeners.get(event);
161
+ if (!set || set.size === 0) return;
162
+
163
+ for (const listener of set) {
164
+ try {
165
+ await listener(payload);
166
+ } catch (error) {
167
+ console.error(`[forma] Error in "${event}" event listener:`, error);
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Check if any listeners are registered for an event.
174
+ */
175
+ hasListeners(event: keyof FormaEventMap): boolean {
176
+ const set = this.listeners.get(event);
177
+ return set !== undefined && set.size > 0;
178
+ }
179
+
180
+ /**
181
+ * Remove all listeners. Called on cleanup.
182
+ */
183
+ clear(): void {
184
+ this.listeners.clear();
185
+ }
186
+ }
package/src/index.ts CHANGED
@@ -95,4 +95,9 @@ export type {
95
95
  LayoutProps,
96
96
  FieldWrapperProps,
97
97
  PageWrapperProps,
98
+
99
+ // Event types
100
+ FormaEventMap,
101
+ FormaEvents,
102
+ FormaEventListener,
98
103
  } from "./types.js";
package/src/types.ts CHANGED
@@ -476,6 +476,13 @@ export type { UseFormaReturn as FormState } from "./useForma.js";
476
476
  export type { UseFormaOptions } from "./useForma.js";
477
477
  export type { PageState, WizardHelpers } from "./useForma.js";
478
478
 
479
+ // Re-export event types
480
+ export type {
481
+ FormaEventMap,
482
+ FormaEvents,
483
+ FormaEventListener,
484
+ } from "./events.js";
485
+
479
486
  // Re-export ValidationResult for convenience
480
487
  export type { ValidationResult } from "@fogpipe/forma-core";
481
488
 
package/src/useForma.ts CHANGED
@@ -25,6 +25,8 @@ import type {
25
25
  GetSelectFieldPropsResult,
26
26
  GetArrayHelpersResult,
27
27
  } from "./types.js";
28
+ import { FormaEventEmitter } from "./events.js";
29
+ import type { FormaEventMap, FormaEvents } from "./events.js";
28
30
  import {
29
31
  getVisibility,
30
32
  getRequired,
@@ -62,6 +64,12 @@ export interface UseFormaOptions {
62
64
  * Set to 0 (default) for immediate validation.
63
65
  */
64
66
  validationDebounceMs?: number;
67
+ /**
68
+ * Declarative event listeners for form lifecycle events.
69
+ * Listeners are stable for the lifetime of the hook — the latest
70
+ * callback is always invoked via refs, without causing dependency changes.
71
+ */
72
+ on?: FormaEvents;
65
73
  }
66
74
 
67
75
  /**
@@ -167,6 +175,15 @@ export interface UseFormaReturn {
167
175
  /** Reset the form */
168
176
  resetForm: () => void;
169
177
 
178
+ /**
179
+ * Register an imperative event listener. Returns an unsubscribe function.
180
+ * Multiple listeners per event are supported; they fire in registration order.
181
+ */
182
+ on: <K extends keyof FormaEventMap>(
183
+ event: K,
184
+ listener: (payload: FormaEventMap[K]) => void | Promise<void>,
185
+ ) => () => void;
186
+
170
187
  // Helper methods for getting field props
171
188
  /** Get props for any field */
172
189
  getFieldProps: (path: string) => GetFieldPropsResult;
@@ -266,6 +283,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
266
283
  validateOn = "blur",
267
284
  referenceData,
268
285
  validationDebounceMs = 0,
286
+ on: onEvents,
269
287
  } = options;
270
288
 
271
289
  // Merge referenceData from options with spec.referenceData
@@ -300,6 +318,42 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
300
318
  // Track if we've initialized (to avoid calling onChange on first render)
301
319
  const hasInitialized = useRef(false);
302
320
 
321
+ // ── Event system ──────────────────────────────────────────────────────
322
+ const emitterRef = useRef(new FormaEventEmitter());
323
+ const onEventsRef = useRef(onEvents);
324
+ onEventsRef.current = onEvents;
325
+ const pendingEventsRef = useRef<
326
+ Array<{ event: keyof FormaEventMap; payload: unknown }>
327
+ >([]);
328
+ const isFiringEventsRef = useRef(false);
329
+
330
+ // Cleanup emitter on unmount
331
+ useEffect(() => {
332
+ const emitter = emitterRef.current;
333
+ return () => {
334
+ emitter.clear();
335
+ };
336
+ }, []);
337
+
338
+ // Helper: fire an event to both declarative `on` handlers and imperative listeners
339
+ const fireEvent = useCallback(
340
+ <K extends keyof FormaEventMap>(
341
+ event: K,
342
+ payload: FormaEventMap[K],
343
+ ) => {
344
+ // Declarative handler (via ref for latest callback)
345
+ try {
346
+ const handler = onEventsRef.current?.[event];
347
+ if (handler) (handler as (p: FormaEventMap[K]) => void)(payload);
348
+ } catch (error) {
349
+ console.error(`[forma] Error in "${event}" event handler:`, error);
350
+ }
351
+ // Imperative listeners
352
+ emitterRef.current.fire(event, payload);
353
+ },
354
+ [],
355
+ );
356
+
303
357
  // Calculate computed values
304
358
  const computed = useMemo(
305
359
  () => calculate(state.data, spec),
@@ -425,24 +479,62 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
425
479
  [state.data],
426
480
  );
427
481
 
482
+ // Helper to get value at nested path
483
+ // Uses stateDataRef to always access current state, avoiding stale closure issues
484
+ const getValueAtPath = useCallback((path: string): unknown => {
485
+ // Handle array index notation: "items[0].name" -> ["items", "0", "name"]
486
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
487
+ let value: unknown = stateDataRef.current;
488
+ for (const part of parts) {
489
+ if (value === null || value === undefined) return undefined;
490
+ value = (value as Record<string, unknown>)[part];
491
+ }
492
+ return value;
493
+ }, []); // No dependencies - uses ref for current state
494
+
495
+ // Queue a fieldChanged event (captures previousValue from current state ref)
496
+ const queueFieldChangedEvent = useCallback(
497
+ (
498
+ path: string,
499
+ value: unknown,
500
+ source: "user" | "reset" | "setValues",
501
+ ) => {
502
+ if (isFiringEventsRef.current) return; // recursion guard
503
+ const previousValue = getValueAtPath(path);
504
+ if (previousValue === value) return; // no actual change
505
+ pendingEventsRef.current.push({
506
+ event: "fieldChanged",
507
+ payload: { path, value, previousValue, source },
508
+ });
509
+ },
510
+ [getValueAtPath],
511
+ );
512
+
428
513
  // Actions
429
514
  const setFieldValue = useCallback(
430
515
  (path: string, value: unknown) => {
516
+ queueFieldChangedEvent(path, value, "user");
431
517
  setNestedValue(path, value);
432
518
  if (validateOn === "change") {
433
519
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
434
520
  }
435
521
  },
436
- [validateOn, setNestedValue],
522
+ [validateOn, setNestedValue, queueFieldChangedEvent],
437
523
  );
438
524
 
439
525
  const setFieldTouched = useCallback((path: string, touched = true) => {
440
526
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched });
441
527
  }, []);
442
528
 
443
- const setValues = useCallback((values: Record<string, unknown>) => {
444
- dispatch({ type: "SET_VALUES", values });
445
- }, []);
529
+ const setValues = useCallback(
530
+ (values: Record<string, unknown>) => {
531
+ for (const [key, value] of Object.entries(values)) {
532
+ queueFieldChangedEvent(key, value, "setValues");
533
+ }
534
+ dispatch({ type: "SET_VALUES", values });
535
+ },
536
+ [queueFieldChangedEvent],
537
+ );
446
538
 
447
539
  const validateField = useCallback(
448
540
  (path: string): FieldError[] => {
@@ -457,26 +549,96 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
457
549
 
458
550
  const submitForm = useCallback(async () => {
459
551
  dispatch({ type: "SET_SUBMITTING", isSubmitting: true });
552
+
553
+ const submissionData = { ...state.data };
554
+ let postSubmitPayload: FormaEventMap["postSubmit"] | undefined;
555
+
460
556
  try {
557
+ // Fire preSubmit (async, inline — listeners can mutate submissionData)
558
+ const preSubmitPayload = {
559
+ data: submissionData,
560
+ computed: { ...computed },
561
+ };
562
+ // Declarative handler
563
+ const declarativePreSubmit = onEventsRef.current?.preSubmit;
564
+ if (declarativePreSubmit) {
565
+ await declarativePreSubmit(preSubmitPayload);
566
+ }
567
+ // Imperative listeners
568
+ if (emitterRef.current.hasListeners("preSubmit")) {
569
+ await emitterRef.current.fireAsync("preSubmit", preSubmitPayload);
570
+ }
571
+
461
572
  // Always use immediate validation on submit to ensure accurate result
462
- if (immediateValidation.valid && onSubmit) {
463
- await onSubmit(state.data);
573
+ if (!immediateValidation.valid) {
574
+ postSubmitPayload = {
575
+ data: submissionData,
576
+ success: false,
577
+ validationErrors: immediateValidation.errors,
578
+ };
579
+ } else if (onSubmit) {
580
+ try {
581
+ await onSubmit(submissionData);
582
+ postSubmitPayload = { data: submissionData, success: true };
583
+ } catch (error) {
584
+ postSubmitPayload = {
585
+ data: submissionData,
586
+ success: false,
587
+ error:
588
+ error instanceof Error ? error : new Error(String(error)),
589
+ };
590
+ }
591
+ } else {
592
+ postSubmitPayload = { data: submissionData, success: true };
464
593
  }
594
+
465
595
  dispatch({ type: "SET_SUBMITTED", isSubmitted: true });
466
596
  } finally {
467
597
  dispatch({ type: "SET_SUBMITTING", isSubmitting: false });
598
+ // Fire postSubmit after state updates
599
+ if (postSubmitPayload) {
600
+ fireEvent("postSubmit", postSubmitPayload);
601
+ }
468
602
  }
469
- }, [immediateValidation, onSubmit, state.data]);
603
+ }, [immediateValidation, onSubmit, state.data, computed, fireEvent]);
470
604
 
471
605
  const resetForm = useCallback(() => {
472
- dispatch({
473
- type: "RESET",
474
- initialData: {
475
- ...getDefaultBooleanValues(spec),
476
- ...getFieldDefaults(spec),
477
- ...initialData,
478
- },
479
- });
606
+ const resetData = {
607
+ ...getDefaultBooleanValues(spec),
608
+ ...getFieldDefaults(spec),
609
+ ...initialData,
610
+ };
611
+
612
+ // Queue fieldChanged for each field that actually changes
613
+ if (!isFiringEventsRef.current) {
614
+ const currentData = stateDataRef.current;
615
+ const allKeys = new Set([
616
+ ...Object.keys(currentData),
617
+ ...Object.keys(resetData),
618
+ ]);
619
+ for (const key of allKeys) {
620
+ const currentVal = currentData[key];
621
+ const resetVal = resetData[key];
622
+ if (currentVal !== resetVal) {
623
+ pendingEventsRef.current.push({
624
+ event: "fieldChanged",
625
+ payload: {
626
+ path: key,
627
+ value: resetVal,
628
+ previousValue: currentVal,
629
+ source: "reset" as const,
630
+ },
631
+ });
632
+ }
633
+ }
634
+ // Queue formReset (fires after fieldChanged events)
635
+ pendingEventsRef.current.push({
636
+ event: "formReset",
637
+ payload: {} as FormaEventMap["formReset"],
638
+ });
639
+ }
640
+
641
+ dispatch({ type: "RESET", initialData: resetData });
480
642
  }, [spec, initialData]);
481
643
 
482
644
  // Wizard helpers
@@ -519,18 +681,45 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
519
681
  currentPageIndex: clampedPageIndex,
520
682
  currentPage,
521
683
  goToPage: (index: number) => {
522
- // Clamp to valid range
523
684
  const validIndex = Math.min(Math.max(0, index), maxPageIndex);
524
- dispatch({ type: "SET_PAGE", page: validIndex });
685
+ if (validIndex !== clampedPageIndex) {
686
+ dispatch({ type: "SET_PAGE", page: validIndex });
687
+ const newPage = visiblePages[validIndex];
688
+ if (newPage) {
689
+ fireEvent("pageChanged", {
690
+ fromIndex: clampedPageIndex,
691
+ toIndex: validIndex,
692
+ page: newPage,
693
+ });
694
+ }
695
+ }
525
696
  },
526
697
  nextPage: () => {
527
698
  if (hasNextPage) {
528
- dispatch({ type: "SET_PAGE", page: clampedPageIndex + 1 });
699
+ const toIndex = clampedPageIndex + 1;
700
+ dispatch({ type: "SET_PAGE", page: toIndex });
701
+ const newPage = visiblePages[toIndex];
702
+ if (newPage) {
703
+ fireEvent("pageChanged", {
704
+ fromIndex: clampedPageIndex,
705
+ toIndex,
706
+ page: newPage,
707
+ });
708
+ }
529
709
  }
530
710
  },
531
711
  previousPage: () => {
532
712
  if (hasPreviousPage) {
533
- dispatch({ type: "SET_PAGE", page: clampedPageIndex - 1 });
713
+ const toIndex = clampedPageIndex - 1;
714
+ dispatch({ type: "SET_PAGE", page: toIndex });
715
+ const newPage = visiblePages[toIndex];
716
+ if (newPage) {
717
+ fireEvent("pageChanged", {
718
+ fromIndex: clampedPageIndex,
719
+ toIndex,
720
+ page: newPage,
721
+ });
722
+ }
534
723
  }
535
724
  },
536
725
  hasNextPage,
@@ -567,53 +756,63 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
567
756
  return pageErrors.length === 0;
568
757
  },
569
758
  };
570
- }, [spec, state.data, state.currentPage, computed, validation, visibility]);
759
+ }, [spec, state.data, state.currentPage, computed, validation, visibility, fireEvent]);
571
760
 
572
- // Helper to get value at nested path
573
- // Uses stateDataRef to always access current state, avoiding stale closure issues
574
- const getValueAtPath = useCallback((path: string): unknown => {
575
- // Handle array index notation: "items[0].name" -> ["items", "0", "name"]
576
- const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
577
- let value: unknown = stateDataRef.current;
578
- for (const part of parts) {
579
- if (value === null || value === undefined) return undefined;
580
- value = (value as Record<string, unknown>)[part];
761
+ // Flush pending events after render (fieldChanged, formReset)
762
+ useEffect(() => {
763
+ const events = pendingEventsRef.current;
764
+ if (events.length === 0) return;
765
+ pendingEventsRef.current = [];
766
+
767
+ isFiringEventsRef.current = true;
768
+ try {
769
+ for (const pending of events) {
770
+ fireEvent(
771
+ pending.event as keyof FormaEventMap,
772
+ pending.payload as FormaEventMap[keyof FormaEventMap],
773
+ );
774
+ }
775
+ } finally {
776
+ isFiringEventsRef.current = false;
581
777
  }
582
- return value;
583
- }, []); // No dependencies - uses ref for current state
778
+ });
584
779
 
585
780
  // Helper to set value at nested path
586
781
  // Uses stateDataRef to always access current state, avoiding stale closure issues
587
- const setValueAtPath = useCallback((path: string, value: unknown): void => {
588
- // For nested paths, we need to build the nested structure
589
- const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
590
- if (parts.length === 1) {
591
- dispatch({ type: "SET_FIELD_VALUE", field: path, value });
592
- return;
593
- }
594
-
595
- // Build nested object from CURRENT state via ref (not stale closure)
596
- const newData = { ...stateDataRef.current };
597
- let current: Record<string, unknown> = newData;
598
-
599
- for (let i = 0; i < parts.length - 1; i++) {
600
- const part = parts[i];
601
- const nextPart = parts[i + 1];
602
- const isNextArrayIndex = /^\d+$/.test(nextPart);
782
+ const setValueAtPath = useCallback(
783
+ (path: string, value: unknown): void => {
784
+ queueFieldChangedEvent(path, value, "user");
785
+ // For nested paths, we need to build the nested structure
786
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
787
+ if (parts.length === 1) {
788
+ dispatch({ type: "SET_FIELD_VALUE", field: path, value });
789
+ return;
790
+ }
603
791
 
604
- if (current[part] === undefined) {
605
- current[part] = isNextArrayIndex ? [] : {};
606
- } else if (Array.isArray(current[part])) {
607
- current[part] = [...(current[part] as unknown[])];
608
- } else {
609
- current[part] = { ...(current[part] as Record<string, unknown>) };
792
+ // Build nested object from CURRENT state via ref (not stale closure)
793
+ const newData = { ...stateDataRef.current };
794
+ let current: Record<string, unknown> = newData;
795
+
796
+ for (let i = 0; i < parts.length - 1; i++) {
797
+ const part = parts[i];
798
+ const nextPart = parts[i + 1];
799
+ const isNextArrayIndex = /^\d+$/.test(nextPart);
800
+
801
+ if (current[part] === undefined) {
802
+ current[part] = isNextArrayIndex ? [] : {};
803
+ } else if (Array.isArray(current[part])) {
804
+ current[part] = [...(current[part] as unknown[])];
805
+ } else {
806
+ current[part] = { ...(current[part] as Record<string, unknown>) };
807
+ }
808
+ current = current[part] as Record<string, unknown>;
610
809
  }
611
- current = current[part] as Record<string, unknown>;
612
- }
613
810
 
614
- current[parts[parts.length - 1]] = value;
615
- dispatch({ type: "SET_VALUES", values: newData });
616
- }, []); // No dependencies - uses ref for current state
811
+ current[parts[parts.length - 1]] = value;
812
+ dispatch({ type: "SET_VALUES", values: newData });
813
+ },
814
+ [queueFieldChangedEvent],
815
+ );
617
816
 
618
817
  // Memoized onChange/onBlur handlers for fields
619
818
  const fieldHandlers = useRef<
@@ -885,6 +1084,16 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
885
1084
  ],
886
1085
  );
887
1086
 
1087
+ // Stable reference for imperative event subscription — only depends on the
1088
+ // emitter ref, so consumers can safely use it as a useEffect dependency.
1089
+ const on = useCallback(
1090
+ <K extends keyof FormaEventMap>(
1091
+ event: K,
1092
+ listener: (payload: FormaEventMap[K]) => void | Promise<void>,
1093
+ ) => emitterRef.current.on(event, listener),
1094
+ [],
1095
+ );
1096
+
888
1097
  return useMemo(
889
1098
  (): UseFormaReturn => ({
890
1099
  data: state.data,
@@ -909,6 +1118,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
909
1118
  validateForm,
910
1119
  submitForm,
911
1120
  resetForm,
1121
+ on,
912
1122
  getFieldProps,
913
1123
  getSelectFieldProps,
914
1124
  getArrayHelpers,
@@ -936,6 +1146,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
936
1146
  validateForm,
937
1147
  submitForm,
938
1148
  resetForm,
1149
+ on,
939
1150
  getFieldProps,
940
1151
  getSelectFieldProps,
941
1152
  getArrayHelpers,