DEV Community

Cover image for ReactJS Hook Pattern ~useEffectEvent Pattern~
Bishoy Semsem
Bishoy Semsem

Posted on • Originally published at bishoy-bishai.github.io

ReactJS Hook Pattern ~useEffectEvent Pattern~

The useEffectEvent Pattern: Taming React's useEffect for Good

If you've spent any significant time with React, chances are you've encountered the infamous useEffect dependency array dance. It usually starts innocently enough: you need to perform a side effect, so you reach for useEffect. Then, you realize the function you're calling inside your effect needs access to the latest state or props. You dutifully add it to the dependency array, and then... either your effect re-runs incessantly, or you introduce useCallback and realize you’ve just pushed the problem up the dependency chain.

It's a common source of frustration, leading to bugs like stale closures, performance issues from over-eager re-renders, and often, a lot of head-scratching. "There must be a better way," I've often thought. And there is. Enter the useEffectEvent pattern.

The Core Problem: Functions in useEffect Dependencies

Let's illustrate the classic scenario. Imagine a component that logs user actions to an analytics service, but only when a specific ID changes.

import React, { useEffect, useState, useCallback } from 'react';

type UserEvent = {
  id: string;
  action: string;
  timestamp: number;
};

function trackAnalytics(event: UserEvent) {
  console.log('Tracking analytics:', event);
  // In a real app, this would send data to a service
}

function UserActivityLogger({ userId }: { userId: string }) {
  const [clickCount, setClickCount] = useState(0);

  // Problematic: This function changes on every render because clickCount changes
  const handleUserClick = () => {
    setClickCount(prev => prev + 1);
    trackAnalytics({ id: userId, action: 'userClicked', timestamp: Date.now() });
  };

  // If we add handleUserClick here, this effect re-runs every time clickCount changes,
  // even though we only want to log when userId changes.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(() => {
    console.log(`User ID changed to ${userId}. Initializing tracking.`);
    // What if we want to log something with the *latest* clickCount here?
    // E.g., trackAnalytics({ id: userId, action: 'init', timestamp: Date.now(), clicks: clickCount });
    // If handleUserClick were referenced here, and we added it to dependencies,
    // the effect would re-run whenever handleUserClick changed, which is often.
  }, [userId]); // Only track userId changes

  return (
    <div>
      <p>Current User ID: {userId}</p>
      <p>Click Count: {clickCount}</p>
      <button onClick={handleUserClick}>Click Me</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this example, if handleUserClick was called within an effect that should only react to userId, adding handleUserClick to that effect's dependency array would cause it to re-run whenever clickCount updates. This is because handleUserClick recreates itself on every render due to its dependency on setClickCount. Even if you wrap handleUserClick in useCallback, you're still forced to include clickCount (or setClickCount which is stable) in its dependencies, which might not be what you want for a different effect.

The fundamental issue is that useEffect's dependency array expects a stable reference. When a function needs to access the latest state/props but not cause the effect to re-run just because those state/props changed, we have a dilemma. useCallback helps with memoizing the function itself, but its dependencies still dictate its stability.

The useEffectEvent Solution: Decoupling Logic from Effect Triggers

The useEffectEvent pattern (which is an RFC for a future React hook called useEvent) provides an elegant way to solve this. The core idea is to create a stable reference to an event handler, while allowing that handler to always read the latest props and state without causing re-renders of the effect it's used in.

It separates two concerns:

  1. When the effect should run (its dependencies).
  2. What the effect does (the logic, which might depend on latest state/props).

Here's how we can implement a useEvent helper hook ourselves using useRef and useLayoutEffect:

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

// A custom hook implementing the useEffectEvent pattern
function useEvent<T extends (...args: any[]) => any>(handler: T): T {
  const handlerRef = useRef<T>(handler);

  // Use a layout effect to update the ref *before* the browser paints.
  // This ensures the ref always points to the latest handler.
  // We use useLayoutEffect because it runs synchronously after all DOM mutations
  // but before the browser has a chance to paint. This avoids tearing.
  useLayoutEffect(() => {
    handlerRef.current = handler;
  }, [handler]); // This effect updates the ref whenever the handler function itself changes

  // Return a stable function that calls the latest handler from the ref.
  // This wrapper function itself is stable, so it can be safely used in useEffect's dependencies.
  const stableHandler = useCallback((...args: Parameters<T>): ReturnType<T> => {
    return handlerRef.current(...args);
  }, []); // Empty dependency array makes this wrapper stable

  return stableHandler as T;
}
Enter fullscreen mode Exit fullscreen mode

How to Use useEvent

Now, let's refactor our UserActivityLogger to use this custom useEvent hook:

import React, { useEffect, useState } from 'react';
// Assume useEvent is imported from our custom hook file

type UserEvent = {
  id: string;
  action: string;
  timestamp: number;
  data?: Record<string, any>;
};

function trackAnalytics(event: UserEvent) {
  console.log('Tracking analytics:', event);
  // In a real app, this would send data to a service
}

function UserActivityLogger({ userId }: { userId: string }) {
  const [clickCount, setClickCount] = useState(0);

  // This handler still captures latest clickCount and userId
  const handleUserInteraction = useEvent(() => {
    setClickCount(prev => prev + 1); // This part is still mutable
    trackAnalytics({ 
      id: userId, 
      action: 'userInteraction', 
      timestamp: Date.now(), 
      data: { currentClicks: clickCount } 
    });
  });

  // Now, use this stable event reference inside an effect
  useEffect(() => {
    const handleScroll = () => {
      // The `handleUserInteraction` reference is stable,
      // but when called, it uses the *latest* `userId` and `clickCount`
      // from the component's render scope where it was defined.
      handleUserInteraction();
    };

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleUserInteraction]); // handleUserInteraction is stable, so this effect runs only once!

  // We can also use it directly as an event handler
  return (
    <div>
      <p>Current User ID: {userId}</p>
      <p>Click Count: {clickCount}</p>
      <button onClick={handleUserInteraction}>Click Me & Track</button>
      <p>Scroll down to trigger an effect-bound interaction.</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice the magic: handleUserInteraction is a function that useEvent returns. This returned function has a stable identity, meaning it doesn't change on every render. Because of this, when we put it into useEffect's dependency array, the effect will only run once (or rather, when userId or other actual dependencies of the effect itself change, which is not the case for handleUserInteraction as it has an empty dependency array for its useCallback wrapper). Yet, when handleUserInteraction is called, it will execute the latest version of the original function you passed to useEvent, which has access to the current userId and clickCount.

Insights and What Most Tutorials Miss

This pattern isn't just a hack; it represents a fundamental shift in how we think about effects and event handlers.

  1. Separation of Concerns: useEffect should declare when something happens. The useEvent pattern allows the what to be fully dynamic, accessing the very latest state and props, without dictating the when. This clarifies your useEffect dependencies immensely.
  2. Predictability: Effects become far more predictable. If your effect needs to react to changes in propA and propB, and call a function doSomething, doSomething doesn't need to be in the dependencies if it's wrapped in useEvent. The effect only re-runs for propA and propB.
  3. Performance & Bug Reduction: No more unnecessary effect re-runs. No more stale closures because useEvent ensures the executed function always reads from the latest render scope.
  4. Not for Memoization: This is crucial. useEvent is not a replacement for useCallback for general memoization of functions to prevent child component re-renders. Its primary purpose is to provide a stable function reference for useEffect dependencies, while internally always invoking the latest version of the logic.

Pitfalls to Avoid

  • Overuse: Not every function needs to be useEvent-wrapped. If a function's dependencies are naturally stable, or if you do want the effect to re-run when the function's captured values change, a plain useCallback might be perfectly fine, or no memoization at all.
  • Misunderstanding useLayoutEffect: The use of useLayoutEffect is critical here to ensure the handlerRef is updated synchronously before the browser repaints. This prevents "tearing" where an event handler might briefly point to an older version of the function if useEffect (which is asynchronous) were used.
  • RFC Status: Remember, useEvent is still an RFC (Request for Comments) for a built-in React hook. While you can implement it yourself as shown, the official API might vary slightly, and its availability is not guaranteed in every React version. For production, consider using a well-vetted library implementation or stick to this pattern knowing its experimental nature.

Key Takeaways

The useEffectEvent pattern, implemented via useEvent, is a powerful tool to bring clarity and stability to your useEffect hooks. It frees you from the tyranny of constantly changing function dependencies, allowing your effects to truly represent when they should react, while the event handler itself always has access to the freshest data. In my experience, adopting this pattern dramatically cleans up complex components and makes reasoning about side effects much easier. Give it a try; your useEffect hooks will thank you!


> 🚀 Read on My Blog

Top comments (0)