{ ILoveJS }

useEventCallback — stable function reference

typescript

A TypeScript hook that returns a stable function reference (identity-stable across renders) while ensuring it always invokes the most recent callback. Essential for optimizing React performance with memoized components.

reacthooksperformancecallback

Code

typescript
import { useRef, useCallback, useLayoutEffect } from 'react';

/**
 * Returns a stable function reference that always calls the latest callback.
 * The returned function's identity never changes between renders.
 * 
 * @template T - Function type extending (...args: any[]) => any
 * @param callback - The callback function to stabilize
 * @returns A stable function reference
 */
function useEventCallback<T extends (...args: never[]) => unknown>(
  callback: T
): T {
  const callbackRef = useRef<T>(callback);

  // Update the ref with the latest callback on every render
  // useLayoutEffect ensures the ref is updated before any event handlers fire
  useLayoutEffect(() => {
    callbackRef.current = callback;
  });

  // Return a stable function that delegates to the current callback
  // useCallback with empty deps ensures this function never changes
  const stableCallback = useCallback(
    ((...args: Parameters<T>): ReturnType<T> => {
      return callbackRef.current(...args) as ReturnType<T>;
    }) as T,
    []
  );

  return stableCallback;
}

export { useEventCallback };

// --- Usage Example ---

import { useState, memo } from 'react';

interface ButtonProps {
  onClick: () => void;
  label: string;
}

// Memoized component that only re-renders when props change
const ExpensiveButton = memo(function ExpensiveButton({ onClick, label }: ButtonProps) {
  console.log('ExpensiveButton rendered');
  return <button onClick={onClick}>{label}</button>;
});

function CounterExample() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Guest');

  // Without useEventCallback, this would create a new function every render
  // causing ExpensiveButton to re-render unnecessarily
  const handleClick = useEventCallback(() => {
    // Always has access to the latest count and name values
    console.log(`Current count: ${count}, User: ${name}`);
    setCount(prev => prev + 1);
  });

  return (
    <div>
      <p>Count: {count}</p>
      <input 
        value={name} 
        onChange={(e) => setName(e.target.value)} 
        placeholder="Enter name"
      />
      {/* ExpensiveButton won't re-render when count/name changes */}
      <ExpensiveButton onClick={handleClick} label="Increment" />
    </div>
  );
}

export { CounterExample };

How It Works

The useEventCallback hook solves a fundamental tension in React between referential stability and closure freshness. When you pass a callback to a memoized child component, React's normal behavior creates a new function on every render, defeating the purpose of memoization. Using useCallback with dependencies helps, but still causes re-renders when those dependencies change. useEventCallback provides a function that never changes identity while always executing with the latest closure values.

The implementation uses a ref to store the latest callback and a useCallback with empty dependencies to create the stable wrapper function. The key insight is that refs are mutable containers that persist across renders without triggering re-renders when updated. We update the ref in useLayoutEffect (rather than useEffect) to ensure the latest callback is stored before any synchronous event handlers might fire after the render commits. This timing is crucial for avoiding stale closure bugs.

The TypeScript generics preserve full type safety. The generic parameter T extends a function type, and we use Parameters and ReturnType utility types to ensure the wrapper function has the exact same signature as the input callback. The 'never[]' constraint on args is a TypeScript pattern that allows any argument array to be accepted while maintaining type inference.

This pattern is particularly valuable when passing callbacks to memoized components (React.memo), adding event listeners in useEffect, or working with third-party libraries that compare function references. It's also useful for callbacks passed to custom hooks that might store the callback in their own refs or effects. However, avoid using this for callbacks that genuinely should trigger re-renders when they change—the semantic meaning of your callback matters.

One edge case to be aware of: during the initial render, before useLayoutEffect runs, the ref already contains the initial callback because we initialize it with the callback value. This ensures the hook works correctly even if the stable function is called synchronously during the first render. Also note that this hook should not be used for callbacks that need to participate in React's concurrent features in complex ways, as the ref update timing might not align with React's scheduling in all edge cases.