Back to Blog
React's useEvent: The Stable Callback You've Been Waiting For
8 min readAug 12, 20260 views

React's useEvent: The Stable Callback You've Been Waiting For

React developers constantly battle stale closures and the performance overhead of `useCallback`. The `useEvent` hook, though not yet stable, offers a potential solution for stable event handlers without the dependency array dance. It's a game-changer for component architecture and performance.

ReactFrontendSoftware DesignPerformance
Share

by Sunil Band

The Perennial Callback Problem

Every React developer has faced the dilemma: you need a stable callback function, but it references state or props that change. Your options? Either you put everything in the useCallback dependency array, leading to re-renders and potentially breaking memoization, or you omit dependencies and risk stale closures, where your callback references outdated values. It's a constant tightrope walk between correctness and performance, often leading to mental fatigue and subtle bugs.

This isn't a new problem. It's inherent to how closures and memoization interact in React's rendering model. The platform gives us the primitives, but the patterns to manage them cleanly for event handlers have always felt like a compromise. You end up with a lot of noise in your useCallback dependencies, or you spend too much time debugging why a specific state update isn't reflected in an event handler that fired a second too late.

Enter useEvent: The Best of Both Worlds

React's proposed useEvent hook aims to solve this fundamental tension. It's currently an RFC and not yet stable or part of a released version of React, but its implications for how we write components are massive. The core idea is simple: create a stable function identity that always reflects the latest state and props, without needing a dependency array. Think of it as a useCallback that never needs to re-create itself and never becomes stale.

This means you can pass event handlers down to memoized children without breaking their memoization. You can use them in useEffect without triggering re-runs every time a dependency changes. It simplifies the mental model significantly: useEvent is for event handlers and other non-rendering logic that needs to refer to the latest values, while useCallback remains for functions passed as props that do need to signal changes to the rendering pipeline.

How it Works (Conceptually)

The magic behind useEvent is that it's designed to run outside the rendering phase. When you define a function with useEvent, React effectively "remembers" its latest version and ensures that any call to the stable useEvent reference executes the most up-to-date logic, even if the component re-rendered multiple times since the useEvent hook was called. This sidesteps the stale closure problem entirely for event handlers.

It doesn't create a new function on every render, which is crucial for memoization. Instead, it provides a stable reference that internally points to the latest definition of your handler. This is fundamentally different from useCallback, which does create a new function if its dependencies change. This distinction is key: useCallback memoizes the function itself based on dependencies; useEvent provides a stable reference to a function whose implementation can change without changing the reference's identity.

A Practical Example: The Interactive List

Let's consider a common scenario: an interactive list where each item has a button that performs an action. We want to log the current count state when an item is clicked, and we also want to ensure the list items themselves are memoized for performance.

The useCallback Headache

First, let's see how you'd typically handle this with useCallback and its associated issues.

typescript
import React, { useState, useCallback, memo } from 'react';

interface ListItemProps {
  id: string;
  onClick: (id: string) => void;
}

// Memoized list item to prevent unnecessary re-renders
const ListItem = memo<ListItemProps>(({ id, onClick }) => {
  console.log(`Rendering ListItem ${id}`);
  return (
    <li style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
      Item {id}
      <button onClick={() => onClick(id)} style={{ marginLeft: '10px' }}>
        Click Me
      </button>
    </li>
  );
});

export default function InteractiveListUseCallback() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState<string[]>(['a', 'b', 'c']);

  // This function needs to access 'count', so 'count' must be a dependency.
  // This means `handleItemClick` changes identity whenever 'count' changes.
  const handleItemClick = useCallback((id: string) => {
    // This will log the LATEST count due to dependency array, but...
    // The identity of handleItemClick changes whenever count changes.
    console.log(`Item ${id} clicked. Current count: ${count}`);
    setCount(prevCount => prevCount + 1);
  }, [count]); // <--- Dependency array: 'count' changes, handleItemClick re-creates

  // This effect will re-run on every render as long as count changes,
  // because handleItemClick's identity changes.
  React.useEffect(() => {
    console.log('handleItemClick identity changed - useEffect re-ran');
  }, [handleItemClick]);

  return (
    <div>
      <h2>Current Count: {count}</h2>
      <button onClick={() => setItems([...items, String(Math.random()).slice(2, 5)])}>
        Add Item
      </button>
      <ul>
        {items.map(item => (
          <ListItem key={item} id={item} onClick={handleItemClick} />
        ))}
      </ul>
    </div>
  );
}

In this useCallback example: if you click an item, handleItemClick logs the correct count because count is in the dependency array. However, this means handleItemClick gets a new identity every time count updates. Since handleItemClick is passed to ListItem, the ListItem component, even though memoized, will re-render unnecessarily because its onClick prop has changed. The useEffect also highlights this re-creation.

The useEvent Solution (RFC Syntax)

Now, let's imagine useEvent is available. The code becomes much cleaner and more performant.

typescript
import React, { useState, memo /* useEvent - hypothetical import */ } from 'react';

// Placeholder for useEvent until it's officially released.
// In a real scenario, you'd import it from 'react'.
// This polyfill-like behavior is for demonstration only and not production-ready.
const useEvent = <T extends (...args: any[]) => any>(handler: T): T => {
  const handlerRef = React.useRef(handler);

  // Update the ref whenever the handler changes, ensuring we always have the latest.
  React.useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);

  // Return a stable function that calls the latest handler from the ref.
  const stableRef = React.useRef(((...args: Parameters<T>) => {
    return handlerRef.current(...args);
  }) as T);

  return stableRef.current;
};

interface ListItemProps {
  id: string;
  onClick: (id: string) => void;
}

const ListItem = memo<ListItemProps>(({ id, onClick }) => {
  console.log(`Rendering ListItem ${id}`);
  return (
    <li style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
      Item {id}
      <button onClick={() => onClick(id)} style={{ marginLeft: '10px' }}>
        Click Me
      </button>
    </li>
  );
});

export default function InteractiveListUseEvent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState<string[]>(['a', 'b', 'c']);

  // With useEvent, the function identity is stable across renders.
  // It always accesses the LATEST 'count' without 'count' being in a dependency array.
  const handleItemClick = useEvent((id: string) => {
    // This will always log the LATEST count without re-creating handleItemClick.
    console.log(`Item ${id} clicked. Current count: ${count}`);
    setCount(prevCount => prevCount + 1);
  }); // <--- No dependency array needed!

  // This effect will only run once because handleItemClick's identity is stable.
  React.useEffect(() => {
    console.log('handleItemClick identity is stable - useEffect ran once');
  }, [handleItemClick]);

  return (
    <div>
      <h2>Current Count: {count}</h2>
      <button onClick={() => setItems([...items, String(Math.random()).slice(2, 5)])}>
        Add Item
      </button>
      <ul>
        {items.map(item => (
          <ListItem key={item} id={item} onClick={handleItemClick} />
        ))}
      </ul>
    </div>
  );
}

In the useEvent example, handleItemClick retains a stable identity across renders. Even though count changes, handleItemClick itself doesn't re-create. This means ListItem receives the same onClick prop every time (unless id or setItems changes, which are stable here), allowing its memoization to work effectively, preventing unnecessary re-renders. The useEffect confirms its stable identity. Crucially, the closure for count is always fresh.

I've included a simple, non-production-ready useEvent polyfill for demonstration. The actual implementation in React's core will be more sophisticated, likely involving deeper integration with the scheduler and event system, but the conceptual model of a stable callback accessing the latest state remains the same.

Trade-offs and Considerations

While useEvent is incredibly promising, it's essential to understand its intended use and current status.

Not for Memoizing Values: useEvent is specifically for event handlers and imperative logic. It is not a replacement for useMemo or useCallback when you need to memoize a computed value or a function whose identity must change to signal a dependency update to another hook (e.g., a prop passed to a useEffect that needs to re-run). If useEvent returned a new identity on every render, it would defeat its primary purpose.

RFC Status: This is the biggest practical trade-off right now. useEvent is an RFC (Request for Comments). It's not yet part of React and its API might change before release. Relying on a custom useEvent polyfill (like the one I wrote for demonstration) in production is generally not a good idea because you're mimicking internal React behavior that could be unstable or diverge from the final API. The real useEvent will likely have guarantees that a userland polyfill cannot provide.

Read-only State: The useEvent proposal generally implies that the function returned by useEvent should not contain logic that directly causes a component to re-render in the current render pass. It's for side effects and event handling, not for deriving values that are part of the current render. Think of it as a "fire and forget" mechanism for actions. This aligns with the mental model of event handlers being outside the render-commit cycle.

When to use useCallback vs. useEvent: If useEvent lands, the distinction will be clear. Use useEvent for event handlers (things like onClick, onChange, onHover) and other imperative APIs that need stable function identity but should always read the latest state. Use useCallback when you need to memoize a function reference that is itself a dependency to another memoization (useMemo, useEffect with specific dependencies) and whose identity must change when its internal dependencies change to trigger re-calculation or re-execution.

Wrapping up

useEvent represents a significant architectural improvement for React applications. It addresses a long-standing pain point around stale closures and useCallback dependency arrays, offering a path to simpler, more performant component logic, especially for event handlers. While it's still in the RFC phase, understanding its concept and potential impact is crucial for staying ahead of the curve in React development.

My advice? Keep an eye on the official React RFCs and discussions around useEvent. When it does land, it will likely become a fundamental part of how we manage callbacks. For now, you can play around with the conceptual polyfill I provided in a sandbox, but don't ship it to production. Instead, reflect on your current codebases and identify places where useEvent could simplify your useCallback dependencies and improve memoization efficiency. It's a mental model shift worth preparing for.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕