Explore Blogs

Rethinking React useEffect with new patterns for 2025

bpi-react-patterns-2025

React developers often rely on useEffect like a Swiss Army knife — but it wasn't meant for everything. As apps scale, useEffect can quickly become a trap: it's hard to reason about, leads to hidden bugs, and often obscures intent. Fortunately, a new wave of mental models is emerging in 2025: ones that emphasize event-driven state, clear subscription patterns, and lifecycle boundaries that match user expectations.

useEffect was introduced to handle side effects in a function-based component world. And it's flexible — maybe too flexible. It often ends up doing:

  • Fetching data
  • Managing subscriptions
  • Syncing props to local state
  • Imperatively manipulating the DOM
  • Acting like a lifecycle method from class components

The catch? Each of those use cases actually benefits from a different abstraction. And using useEffect for all of them leads to bloated, interleaved logic.

Event-based state management

Rather than relying on useEffect to update component state after props change or something external happens, treat state changes as events.

What this looks like:

// Outside the component
const userStore = createStore();

function onUserLogin(data) {
  userStore.emit('login', data);
}

// Inside React
useSyncExternalStore(
  userStore.subscribe,
  () => userStore.getCurrentUser()
);
  • No more "watching for props" in useEffect
  • State comes in through events, and components react declaratively

This is perfect for authentication flows, WebSocket updates, or complex UI interactions like tabs, modals, or wizards.

Not every effect is bad — but every effect should earn its place.

The problem with useEffect-based subscriptions:

  • You have to clean them up manually.
  • You often mix "subscribe" and "respond" logic in one place.
  • React can re-run them even when nothing changed.

In contrast, useSyncExternalStore:

  • Keeps the source of truth outside the component
  • Tells React exactly when to re-render
  • Works with concurrent features and suspense

Minimal Example:

const themeStore = {
  subscribe: (cb) => {
    window.addEventListener('themeChange', cb);
    return () => window.removeEventListener('themeChange', cb);
  },
  getSnapshot: () => document.documentElement.dataset.theme,
};

const theme = useSyncExternalStore(themeStore.subscribe, themeStore.getSnapshot);

No effect hooks. No race conditions. Just subscriptions that work.

Lifecycle separation

React 18+ encourages splitting side effects into reactive and non-reactive logic. You don't need to do everything on mount.

Refactor useEffect like this:

Instead of:

useEffect(() => {
  fetchUser();
}, []);

Do:

// Fetch before render using suspense
const user = use(fetchUser());

Or use event boundaries:

<button onClick={() => refetchUser()}>Retry</button>

Now the side-effect is tied to a user action or lifecycle edge, not a generic mount trigger.

When to still use useEffect

We're not throwing it away entirely. Keep it for:

  • Imperative DOM actions (e.g., focusing an input)
  • 3rd-party integrations (e.g., chart libraries, maps)
  • Non-reactive flows (e.g., fire-and-forget analytics)

But always ask: Is this a side-effect of render, or can it be event-driven, declarative, or outside React?


Let's walk through a few common cases where useEffect is traditionally used — and how to remove or replace it using 2025-ready patterns.

WebSocket chat app

Instead of:

useEffect(() => {
  const socket = new WebSocket(url);
  socket.onmessage = (msg) => setMsgs(prev => [...prev, msg]);
  return () => socket.close();
}, []);

Do:

chatStore.subscribe((msg) => update(msg));

And update using useSyncExternalStore or context directly. Side-effect logic lives in your store, not React.

Refetch on param change

Instead of:

useEffect(() => {
  fetchData(id);
}, [id]);

Do:

const data = use(fetchData(id));

Suspense handles lifecycle. No effect hooks needed.

Refactoring real apps

User profile feature: From useEffect to reactive simplicity

Here's a feature many apps have: showing and updating a user profile with theming based on preferences.

Traditional with useEffect:

const [user, setUser] = useState(null);

useEffect(() => {
  fetch('/api/user/' + id)
    .then(res => res.json())
    .then(setUser);
}, [id]);

useEffect(() => {
  if (user?.settings?.darkMode) {
    document.body.classList.add('dark');
  } else {
    document.body.classList.remove('dark');
  }
}, [user]);

Refactored with 2025 patterns:

const user = use(fetchUser(id)); // Suspense-powered
useDarkMode(user?.settings?.darkMode); // Custom hook

And the useDarkMode implementation:

function useDarkMode(enabled) {
  useLayoutEffect(() => {
    document.body.classList.toggle('dark', !!enabled);
  }, [enabled]);
}

This shows:

  • Logic separation: Fetching, theming, and rendering are now cleanly decoupled.
  • Declarative flow: No imperative setUser or DOM logic in main component.
  • Less fragile: Easier to test and modify each concern individually.

useEffect alternatives – 2025 comparison table

Many developers use useEffect for everything — but React 18+ and modern patterns give us clearer, more declarative alternatives. This table breaks down common use cases and their best-fit solutions today:

Use caseTypical useEffect usageModern 2025 replacement
Fetching dataFetch in useEffect then setStateuse(fetchDataFn()) with Suspense or react-query, etc.
Subscribing to live updatesSubscribe in useEffect, return cleanupuseSyncExternalStore or state libraries like Zustand
Local storage syncListen to storage event in useEffectCustom hook with useEventListener or event store
Logging / analyticsFire tracking inside useEffectTrigger on event handlers or lifecycle boundaries
Triggering animationAnimate in useEffectuseLayoutEffect, IntersectionObserver, or Framer Motion
Scroll position trackingAttach listener in useEffect, cleanup on unmountuseSyncExternalStore with scroll snapshot function
Updating the page titleuseEffect(() => { document.title = ... }, [...])Custom useDocumentTitle hook
Reacting to prop changesCompare previous props in useEffectDerive directly in render or memoize

This comparison helps developers:

  • Reduce side effects to only when truly necessary
  • Replace imperative flows with subscription models
  • Avoid timing bugs and lifecycle misfires

Let's compare old useEffect-driven patterns with 2025-native patterns using useSyncExternalStore, Suspense, and smart subscriptions.

Tracking cursor position

useEffect — Old approach

function useCursorOld() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handle = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handle);
    return () => window.removeEventListener('mousemove', handle);
  }, []);

  return pos;
}

useSyncExternalStore — Modern approach

let pos = { x: 0, y: 0 };

export function useCursorPosition() {
  return useSyncExternalStore(
    (cb) => {
      const handler = (e) => {
        pos = { x: e.clientX, y: e.clientY };
        cb();
      };
      window.addEventListener('mousemove', handler);
      return () => window.removeEventListener('mousemove', handler);
    },
    () => pos
  );
}

Subscription replaces internal state + effect completely — tear-free, concurrent-safe, and zero rerender traps.

Dark mode detection

useEffect — Old approach

function useDarkOld() {
  const [dark, setDark] = useState(false);

  useEffect(() => {
    const mql = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = () => setDark(mql.matches);
    mql.addEventListener('change', handler);
    handler();
    return () => mql.removeEventListener('change', handler);
  }, []);

  return dark;
}

useSyncExternalStore — Modern approach

export function usePrefersDark() {
  return useSyncExternalStore(
    (cb) => {
      const mql = window.matchMedia('(prefers-color-scheme: dark)');
      mql.addEventListener('change', cb);
      return () => mql.removeEventListener('change', cb);
    },
    () => window.matchMedia('(prefers-color-scheme: dark)').matches
  );
}

Removes manual state sync. React knows exactly when to re-render.

Data fetching without managing state

useEffect — Old approach

function useUserOld(id) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/user/${id}`).then(res => res.json()).then(setUser);
  }, [id]);

  return user;
}

use — Modern approach

const user = use(fetchUser(id)); // Works with Suspense + cache

Avoids loading state, avoids race conditions, and simplifies reuse with built-in deduplication.


Rethinking react as a projection layer, not a state machine

React was never really a state management library. It's a declarative UI renderer — and 2025 React is finally being used that way.

So what does it mean to treat React as a projection layer?

Traditional: Treating react as the source of truth

In typical apps:

  • React manages all state: forms, modals, data, timers, toggles.
  • useState, useEffect, useReducer, context... even for ephemeral UI.

This leads to:

  • Complex chains of effects
  • Shared state becoming deeply coupled
  • Components "owning" things they shouldn't
  • Re-renders just to reflect non-React changes (e.g., mouse, window, URL)

Projection layer model: One-way render from reality

Instead, treat React as a view of external realities:

Source of truthAccessed in React with
URL + paramsuseLocation(), useParams(), useSearchParams()
Server state (user, posts)use(fetch()), Suspense cache, react-query
Media queries / themematchMedia, useSyncExternalStore
Browser events (scroll, cursor)useSyncExternalStore or DOM subscriptions
App logic & eventsPub/sub, observables, signals

The mental shift: React doesn't hold state — it reflects it.

Examples of React as a projection layer

  • No more local UI toggles: use derived props from URL, context, or events
  • No manual setState: project external change via subscription
  • No need for local loading state: use Suspense + streaming

Benefits

  • Easier testing (React becomes stateless!)
  • Fewer bugs (no desync between internal and real state)
  • More reuse (UI just projects state)
  • Better cacheability (especially for SPAs behaving like SSR)
  • Plays well with external tools (analytics, native APIs, etc.)

Real-world case – Modal state from the URL, not local state

Let's say we have a blog app. Clicking a post opens a details modal — but we want the modal to be shareable via URL.

State-centric with useState and useEffect — Old approach

function Blog() {
  const [openPostId, setOpenPostId] = useState(null);

  return (
    <>
      {posts.map((post) => (
        <div
          key={post.id}
          onClick={() => setOpenPostId(post.id)}
        >
          {post.title}
        </div>
      ))}
      {openPostId && (
        <PostModal
          id={openPostId}
          onClose={() => setOpenPostId(null)}
        />
      )}
    </>
  );
}

Problems:

  • Not deep-linkable (can't open modal via URL)
  • Doesn't preserve state on refresh
  • useState + useEffect required to sync open/close manually

Modal state derived from URL — Modern approach

import { useSearchParams, useNavigate } from 'react-router-dom';

function Blog() {
  const [params, setParams] = useSearchParams();
  const openPostId = params.get('post');

  return (
    <>
      {posts.map((post) => (
        <div
          key={post.id}
          onClick={() => setParams({ post: post.id })}
        >
          {post.title}
        </div>
      ))}

      {openPostId && (
        <PostModal
          id={openPostId}
          onClose={() => setParams({})}
        />
      )}
    </>
  );
}

Now:

  • Deep linkable: ?post=123 opens the modal
  • Refresh-safe
  • Shareable
  • No manual useEffect required
  • React simply projects what the URL says

This kind of refactor fits beautifully with server-driven UIs, analytics-friendly SPAs, and edge-cacheable HTML.

useEffect vs modern alternatives – Feature comparison table

Feature / Use caseStill use useEffect?Modern alternativeWhy it's better (When applicable)
Sync with browser APIs (e.g., resize, scroll)❌ NouseSyncExternalStoreConcurrent-safe, decouples from React state
Fetching data from server❌ NoSuspense, React Query, RSCRemoves loading boilerplate, streamable
Syncing local state to URL❌ NoURL params via useSearchParamsShareable, history-aware, refresh-safe
Listening to media queries❌ NomatchMedia + useSyncExternalStoreDeclarative and clean
Animations / transitions after render✅ YesuseEffect(() => triggerAnimation(), [])Side-effects based on DOM ready state
Manually subscribing to WebSocket events✅ YesuseEffect with cleanupGood for external resource lifecycles
Responding to prop changes (imperative logic)✅ YesuseEffect(() => doThing(), [prop])React still needs this occasionally
Debounced or throttled state updates❌ NoEvent emitter, useDeferredValueAvoids extra renders, easier to isolate logic
Setting timeouts or intervals✅ YesuseEffect + setTimeout/setIntervalStill the right tool for temporal side effects
Toggle modals/tooltips❌ NoURL state, context, prop-based renderingDeclarative, syncs across app and URL

Summary

  • Use useEffect only when you truly need to react to a change or handle non-declarative side effects.
  • For everything else — from data, UI state, subscriptions, and browser features — 2025 React has cleaner tools.

The end of useEffect as we know it?

The React ecosystem isn't moving away from useEffect because it's broken — it's moving beyond it because we've finally outgrown it. It served us well when React was the source of truth. But the future is declarative, cacheable, streamable, and event-driven.

By embracing modern patterns — projecting from URLs, subscribing to external stores, syncing with real-world state — we get less boilerplate, fewer bugs, and more expressive UIs. And the best part? React becomes what it was always meant to be: a beautiful, predictable way to render state — not manage it.

So next time your instinct says "I'll just drop in a useEffect", pause.

Ask yourself:

Is this a side-effect...
Or is it just the wrong mental model?

Because in 2025, the best React code is often the one that doesn't reach for useEffect at all.


Further clarification:

useEffect isn't dead — It's just not the first tool anymore

  • useEffect is still essential for things that are truly side effects:
    • Subscribing to WebSockets
    • Setting up timers or intervals
    • Manually interacting with non-React systems (e.g. chart libraries, maps)
    • Running code after render that the DOM depends on

But:

  • For 80% of what we used to reach for useEffect, there are now better, more declarative, more cacheable patterns:
    • Derived state from URL or context
    • Data fetching via Suspense, React Query, or RSC
    • Syncing with external systems using useSyncExternalStore

Think of it like this:

  • In 2019: "Everything needs useEffect."
  • In 2025: "Wait — do I even need an effect here?"

So no, useEffect isn't gone — but it's no longer the center of the React universe.

React's future doesn't eliminate useEffect — it narrows its scope. From fetches to side-effects to subscriptions, newer primitives like use, event-based stores, and declarative subscriptions let you separate concerns with clarity. Mastering these patterns means writing React code that scales effortlessly and explains itself.

Stay Updated

This site is protected by reCAPTCHA and the GooglePrivacy Policy andTerms of Service apply.