@@ -14,20 +14,32 @@ function useFormState<FormValues = Record<string, any>>({
1414 const onChangeRef = React . useRef ( onChange ) ;
1515 onChangeRef . current = onChange ;
1616
17- // Initialize state with current form state without callbacks
18- const [ state , setState ] = React . useState < FormState < FormValues > > ( ( ) => {
19- // Get initial state synchronously but without callbacks
20- return form . getState ( ) ;
21- } ) ;
17+ // Initialize with current form state WITHOUT triggering callbacks during render.
18+ // We intentionally use getState() here so render-prop consumers (e.g. <FormSpy>{...})
19+ // can read a fully-populated initial state on first render.
20+ const [ state , setState ] = React . useState < FormState < FormValues > > ( ( ) =>
21+ form . getState ( ) ,
22+ ) ;
23+
24+ // We want `onChange` to be called AFTER render (fixes #809) and only with the
25+ // subscription-filtered state.
26+ const firstSubscriptionRef = React . useRef ( true ) ;
27+ const pendingOnChangeRef = React . useRef < FormState < FormValues > | null > ( null ) ;
28+ const lastOnChangeRef = React . useRef < FormState < FormValues > | null > ( null ) ;
2229
2330 React . useEffect ( ( ) => {
24- // Subscribe to form state changes after initial render
2531 const unsubscribe = form . subscribe ( ( newState ) => {
32+ // Ensure we set state at least once from the subscription, even if equal,
33+ // so that `onChange` can be fired from an effect after the first render.
34+ const isFirst = firstSubscriptionRef . current ;
35+ if ( isFirst ) {
36+ firstSubscriptionRef . current = false ;
37+ }
38+
39+ pendingOnChangeRef . current = newState ;
40+
2641 setState ( ( prevState ) => {
27- if ( ! shallowEqual ( newState , prevState ) ) {
28- if ( onChangeRef . current ) {
29- onChangeRef . current ( newState ) ;
30- }
42+ if ( isFirst || ! shallowEqual ( newState , prevState ) ) {
3143 return newState ;
3244 }
3345 return prevState ;
@@ -38,6 +50,23 @@ function useFormState<FormValues = Record<string, any>>({
3850 // eslint-disable-next-line react-hooks/exhaustive-deps
3951 } , [ ] ) ;
4052
53+ React . useEffect ( ( ) => {
54+ const pending = pendingOnChangeRef . current ;
55+ if ( ! pending || ! onChangeRef . current ) {
56+ return ;
57+ }
58+
59+ // Only fire when the subscription has produced a new state and it differs
60+ // from what we've already emitted.
61+ if ( lastOnChangeRef . current === null || ! shallowEqual ( pending , lastOnChangeRef . current ) ) {
62+ onChangeRef . current ( pending ) ;
63+ lastOnChangeRef . current = pending ;
64+ }
65+
66+ // Clear pending once we've handled it.
67+ pendingOnChangeRef . current = null ;
68+ } , [ state ] ) ;
69+
4170 const lazyState = { } ;
4271 addLazyFormState ( lazyState , state ) ;
4372 return lazyState as FormState < FormValues > ;
0 commit comments