Explore Blogs
Rethinking React useEffect with new patterns for 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 case | Typical useEffect usage | Modern 2025 replacement |
---|---|---|
Fetching data | Fetch in useEffect then setState | use(fetchDataFn()) with Suspense or react-query , etc. |
Subscribing to live updates | Subscribe in useEffect , return cleanup | useSyncExternalStore or state libraries like Zustand |
Local storage sync | Listen to storage event in useEffect | Custom hook with useEventListener or event store |
Logging / analytics | Fire tracking inside useEffect | Trigger on event handlers or lifecycle boundaries |
Triggering animation | Animate in useEffect | useLayoutEffect , IntersectionObserver , or Framer Motion |
Scroll position tracking | Attach listener in useEffect , cleanup on unmount | useSyncExternalStore with scroll snapshot function |
Updating the page title | useEffect(() => { document.title = ... }, [...]) | Custom useDocumentTitle hook |
Reacting to prop changes | Compare previous props in useEffect | Derive 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 truth | Accessed in React with |
---|---|
URL + params | useLocation() , useParams() , useSearchParams() |
Server state (user, posts) | use(fetch()) , Suspense cache, react-query |
Media queries / theme | matchMedia , useSyncExternalStore |
Browser events (scroll, cursor) | useSyncExternalStore or DOM subscriptions |
App logic & events | Pub/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 case | Still use useEffect ? | Modern alternative | Why it's better (When applicable) |
---|---|---|---|
Sync with browser APIs (e.g., resize, scroll) | ❌ No | useSyncExternalStore | Concurrent-safe, decouples from React state |
Fetching data from server | ❌ No | Suspense, React Query, RSC | Removes loading boilerplate, streamable |
Syncing local state to URL | ❌ No | URL params via useSearchParams | Shareable, history-aware, refresh-safe |
Listening to media queries | ❌ No | matchMedia + useSyncExternalStore | Declarative and clean |
Animations / transitions after render | ✅ Yes | useEffect(() => triggerAnimation(), []) | Side-effects based on DOM ready state |
Manually subscribing to WebSocket events | ✅ Yes | useEffect with cleanup | Good for external resource lifecycles |
Responding to prop changes (imperative logic) | ✅ Yes | useEffect(() => doThing(), [prop]) | React still needs this occasionally |
Debounced or throttled state updates | ❌ No | Event emitter, useDeferredValue | Avoids extra renders, easier to isolate logic |
Setting timeouts or intervals | ✅ Yes | useEffect + setTimeout /setInterval | Still the right tool for temporal side effects |
Toggle modals/tooltips | ❌ No | URL state, context, prop-based rendering | Declarative, 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.