From 8f86e0211610c77ec5dbcbae94a051485d571168 Mon Sep 17 00:00:00 2001 From: kyukyu-dev Date: Tue, 14 Oct 2025 01:33:19 +0900 Subject: [PATCH] refactor(useControlledState): use ref to store latest onChange handler and prevent stale closure --- .../useControlledState/useControlledState.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/hooks/useControlledState/useControlledState.ts b/src/hooks/useControlledState/useControlledState.ts index 43c5ce92..8a77227b 100644 --- a/src/hooks/useControlledState/useControlledState.ts +++ b/src/hooks/useControlledState/useControlledState.ts @@ -1,4 +1,15 @@ -import { type Dispatch, type SetStateAction, useCallback, useState } from 'react'; +import React, { type Dispatch, type SetStateAction, useCallback, useRef, useState } from 'react'; + +const useInsertionEffect = (React as Record)[ + `useInsertionEffect${Math.random().toFixed(1)}`.slice(0, -3) +]; +const useSafeInsertionEffect = + // React 17 doesn't have useInsertionEffect. + typeof useInsertionEffect === 'function' && + // Preact replaces useInsertionEffect with useLayoutEffect and fires too late. + useInsertionEffect !== React.useLayoutEffect + ? useInsertionEffect + : (fn: React.EffectCallback) => fn(); type ControlledState = { value: T; defaultValue?: never } | { defaultValue: T; value?: T }; @@ -52,6 +63,12 @@ export function useControlledState({ const controlled = valueProp !== undefined; const value = controlled ? valueProp : uncontrolledState; + const onChangeRef = useRef(onChange); + + useSafeInsertionEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + const setValue = useCallback( (next: SetStateAction) => { const nextValue = isSetStateAction(next) ? next(value) : next; @@ -59,9 +76,9 @@ export function useControlledState({ if (equalityFn(value, nextValue) === true) return; if (controlled === false) setUncontrolledState(nextValue); if (controlled === true && nextValue === undefined) setUncontrolledState(nextValue); - onChange?.(nextValue); + onChangeRef.current?.(nextValue); }, - [controlled, onChange, equalityFn, value] + [controlled, equalityFn, value] ); return [value, setValue];