Tech Verse Logo
Enable dark mode
Stop Re-rendering: When to Drop useEffect in React 19

Stop Re-rendering: When to Drop useEffect in React 19

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

Here's a pattern that shows up in production codebases all the time: a component receives props, copies them into local state, and updates that state inside a useEffect hook whenever the props change. It feels reactive, but it introduces hidden performance hits and maintenance problems.

1. Derived State: Stop Copying Props into State

Consider a component displaying a filtered list of orders in an admin dashboard built with Next.js 16 and React 19:

// Bad: Double render cycle and redundant state
'use client';

import { useState, useEffect } from 'react';

export function OrderList({ orders, filterStatus }) {
  const [filteredOrders, setFilteredOrders] = useState([]);

  useEffect(() => {
    // This triggers a second render after props change!
    setFilteredOrders(
      orders.filter((order) => order.status === filterStatus)
    );
  }, [orders, filterStatus]);

  return (
    <ul>
      {filteredOrders.map((order) => (
        <li key={order.id}>{order.reference} - {order.status}</li>
      ))}
    </ul>
  );
}

This approach drops performance. When filterStatus changes, React renders OrderList with old state, runs the effect after painting or during commit phase, updates filteredOrders, and forces React to render the entire component a second time. On mobile devices, users notice subtle UI flickers. In heavy lists, it inflates render time from 8ms to 45ms.

Instead, calculate the derived value directly in the body of the render function:

// Good: Derived directly during render
'use client';

export function OrderList({ orders, filterStatus }) {
  // Calculated on the fly, zero extra renders
  const filteredOrders = orders.filter(
    (order) => order.status === filterStatus
  );

  return (
    <ul>
      {filteredOrders.map((order) => (
        <li key={order.id}>{order.reference} - {order.status}</li>
      ))}
    </ul>
  );
}

If calculating filteredOrders involves thousands of items and takes more than 10ms, wrap it in useMemo. But for 95% of array operations under 1,000 items, plain JavaScript execution takes less than 0.2ms. Skip the hook entirely.

What if you need to reset local state when a prop changes? For instance, resetting a comment text field when changing selected items. Don't use an effect to call setComment(''). Pass an explicit key prop to the component from the parent (e.g., <CommentForm key={selectedItemId} />). React unmounts the old instance and mounts a fresh one with reset state automatically.

2. User Actions Belong in Event Handlers

Another classic mistake is putting side effects caused by user interaction inside a useEffect block that watches a piece of state.

Imagine a checkout button that sets a isSubmitting flag, which then triggers a POST request to a Laravel 12 backend inside an effect:

// Bad: Cascading state triggers HTTP request inside useEffect
'use client';

import { useState, useEffect } from 'react';

export function CheckoutButton({ cartId }) {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      fetch(`/api/v1/checkout/${cartId}`, { method: 'POST' })
        .then((res) => res.json())
        .then((data) => handleSuccess(data));
    }
  }, [submitted, cartId]);

  return (
    <button onClick={() => setSubmitted(true)}>
      Complete Purchase
    </button>
  );
}

Why is this dangerous? First, if cartId changes while submitted is true, the effect fires a duplicate request. Second, you lose the event context. You don't know if submitted became true because the user clicked the button or because state restored from cache.

Side effects caused by specific actions (button clicks, form submits, key presses) belong strictly inside event handlers:

// Good: Direct side effect inside event handler
'use client';

import { useState } from 'react';

export function CheckoutButton({ cartId }) {
  const [isPending, setIsPending] = useState(false);

  async function handleCheckout() {
    setIsPending(true);
    try {
      const response = await fetch(`/api/v1/checkout/${cartId}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      });
      const data = await response.json();
      handleSuccess(data);
    } catch (error) {
      showErrorNotice(error);
    } finally {
      setIsPending(false);
    }
  }

  return (
    <button onClick={handleCheckout} disabled={isPending}>
      {isPending ? 'Processing...' : 'Complete Purchase'}
    </button>
  );
}

In React 19 and Next.js 16, you can also take advantage of Server Actions and useActionState or useTransition to manage pending states cleanly without manually tracking boolean flags.

3. Dependency Array Traps and Infinite Loops

The dependency array in useEffect creates a trap when object references or inline functions are included. In React 19, strict mode catches some of these in development, but production builds will still hit infinite render loops or stale closures.

Object and Array Instantiation inside Render

If you pass an object literal directly into an effect dependency array, React compares references using Object.is. Every render produces a new reference, forcing the effect to run on every single frame.

// Infinite Loop Trap
function UserDashboard({ userId }) {
  const config = { headers: { Authorization: 'Bearer token' } }; // New reference every render

  useEffect(() => {
    fetchUserData(userId, config);
  }, [userId, config]); // Triggered endlessly!
}

Fix this by lifting static objects outside the component or declaring them inside the effect body itself:

// Correct: Config declared inside the effect scope
function UserDashboard({ userId }) {
  useEffect(() => {
    const config = { headers: { Authorization: 'Bearer token' } };
    fetchUserData(userId, config);
  }, [userId]);
}

Stale Closures in Event Listeners

When attaching global event listeners like window scroll handlers, forgetting dependencies causes the handler to close over outdated state variables. Adding the state variable to the dependencies re-binds the listener every time state updates, negating performance optimizations.

Use mutable refs to store the latest callback value when working with asynchronous triggers without re-triggering the effect cleanup and setup cycles repeatedly.

4. So When Should You Actually Use useEffect?

Reserve useEffect for one specific duty: synchronizing your React component with external systems that aren't managed by React's rendering pipeline.

Valid use cases include:

<ul><li><strong>Third-party libraries:</strong> Initializing a non-React canvas engine, Google Maps instance, or D3 chart.</li><li><strong>Browser subscriptions:</strong> Listening to WebSocket connections, IntersectionObserver, or window resize events.</li><li><strong>DOM management:</strong> Managing manual focus states or calculating layout geometries that rely on getBoundingClientRect().</li></ul>

If your code fits into one of these buckets, use useEffect and ensure you clean up properly in the returned teardown function (e.g., calling socket.disconnect() or observer.disconnect()).

If you're syncing React state with React state, stop. Calculate it in render or place the work inside your handlers.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    React Context: When to Reach for a Store

    React Context: When to Reach for a Store

    Custom React Hooks: Abstraction vs Indirection

    Custom React Hooks: Abstraction vs Indirection

    Stop Wasting React Performance on memo and useMemo

    Stop Wasting React Performance on memo and useMemo

    Next.js Partial Prerendering: Static Shells, Dynamic Holes

    Next.js Partial Prerendering: Static Shells, Dynamic Holes

    Stop Re-rendering: When to Drop useEffect in React 19

    Stop Re-rendering: When to Drop useEffect in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Generating Open Graph Images at the Edge in Next.js 16

    Generating Open Graph Images at the Edge in Next.js 16

    Fix Next.js Third Party Script Performance

    Fix Next.js Third Party Script Performance

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes