diff --git a/src/useFormState.ts b/src/useFormState.ts index 18781d8..a2a65a3 100644 --- a/src/useFormState.ts +++ b/src/useFormState.ts @@ -14,20 +14,32 @@ function useFormState>({ const onChangeRef = React.useRef(onChange); onChangeRef.current = onChange; - // Initialize state with current form state without callbacks - const [state, setState] = React.useState>(() => { - // Get initial state synchronously but without callbacks - return form.getState(); - }); + // Initialize with current form state WITHOUT triggering callbacks during render. + // We intentionally use getState() here so render-prop consumers (e.g. {...}) + // can read a fully-populated initial state on first render. + const [state, setState] = React.useState>(() => + form.getState(), + ); + + // We want `onChange` to be called AFTER render (fixes #809) and only with the + // subscription-filtered state. + const firstSubscriptionRef = React.useRef(true); + const pendingOnChangeRef = React.useRef | null>(null); + const lastOnChangeRef = React.useRef | null>(null); React.useEffect(() => { - // Subscribe to form state changes after initial render const unsubscribe = form.subscribe((newState) => { + // Ensure we set state at least once from the subscription, even if equal, + // so that `onChange` can be fired from an effect after the first render. + const isFirst = firstSubscriptionRef.current; + if (isFirst) { + firstSubscriptionRef.current = false; + } + + pendingOnChangeRef.current = newState; + setState((prevState) => { - if (!shallowEqual(newState, prevState)) { - if (onChangeRef.current) { - onChangeRef.current(newState); - } + if (isFirst || !shallowEqual(newState, prevState)) { return newState; } return prevState; @@ -38,6 +50,23 @@ function useFormState>({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + React.useEffect(() => { + const pending = pendingOnChangeRef.current; + if (!pending || !onChangeRef.current) { + return; + } + + // Only fire when the subscription has produced a new state and it differs + // from what we've already emitted. + if (lastOnChangeRef.current === null || !shallowEqual(pending, lastOnChangeRef.current)) { + onChangeRef.current(pending); + lastOnChangeRef.current = pending; + } + + // Clear pending once we've handled it. + pendingOnChangeRef.current = null; + }, [state]); + const lazyState = {}; addLazyFormState(lazyState, state); return lazyState as FormState;