Apr 25, 2026
React useEffect Best Practices for Next.js Apps
A practical guide to React useEffect best practices for Next.js apps covering effect boundaries, cleanup patterns, data fetching tradeoffs, and safer client components.
7 min read
React useEffect Best Practices for Next.js Apps
If you are searching for React useEffect best practices, you are probably dealing with a component that works locally but feels fragile in production. Maybe an effect runs twice in development, a request finishes after the user leaves the page, or a client component keeps drifting away from the server-rendered HTML. useEffect is still useful, but it is also one of the easiest React APIs to overuse.
This guide focuses on practical React useEffect best practices for modern Next.js apps. We will cover useEffect data fetching Next.js tradeoffs, durable React effect cleanup patterns, dependency rules, and when an effect should be replaced by Server Components, event handlers, or a server-state library. If the problem is remote data, read TanStack Query with Next.js App Router: Server State Without useEffect next. For server-first boundaries, Next.js Server Components Patterns for Faster App Router Apps is the stronger starting point. And if the symptom is mismatched HTML after hydration, React Hydration Mismatch in Next.js: Causes, Fixes, and Prevention explains the failure mode in detail.
Start by asking whether you need an effect
The best useEffect is often the one you delete. Effects are for synchronizing React with something outside React:
- browser APIs such as
localStorage,matchMedia, orIntersectionObserver - subscriptions, sockets, timers, and event listeners
- imperative third-party widgets
- analytics and telemetry calls
- client-only work that cannot run during server rendering
Effects are weaker when they are used for derived state, button-click logic, or routine data fetching. If a value can be calculated during render, calculate it during render. If logic happens because the user clicked a button, keep it in the event handler. If the data can be fetched in a Server Component, fetch it on the server.
type Invoice = {
id: string;
total: number;
paid: boolean;
};
export function InvoiceSummary({ invoices }: { invoices: Invoice[] }) {
const unpaidTotal = invoices
.filter((invoice) => !invoice.paid)
.reduce((sum, invoice) => sum + invoice.total, 0);
return <p>Unpaid total: ${unpaidTotal}</p>;
}
No effect is needed here. The total is derived from props. Adding useEffect and useState would create an extra render and a second source of truth.
Keep effects tied to one responsibility
One of the most reliable React useEffect best practices is to keep each effect focused. If one effect updates the page title, subscribes to a socket, writes to storage, and fires analytics, its dependency list becomes difficult to review.
Prefer separate effects when the synchronization targets are separate:
"use client";
import { useEffect } from "react";
export function ProjectView({ projectId, title }: { projectId: string; title: string }) {
useEffect(() => {
document.title = `${title} | Dashboard`;
}, [title]);
useEffect(() => {
analytics.track("project_viewed", { projectId });
}, [projectId]);
return <h1>{title}</h1>;
}
Each effect now has an obvious dependency contract. The title effect depends on title. The analytics effect depends on projectId. Mixing them would make both behaviors harder to reason about.
Treat the dependency array as a contract
The dependency array is not a performance hint. It describes which values the effect reads from React scope. If the effect uses a prop, state value, or function defined in the component, that value usually belongs in the dependency list.
Weak pattern:
useEffect(() => {
syncFilters(filters);
}, []);
This looks stable, but it freezes the first filters value forever. A future developer will change the filter UI and wonder why the sync layer is stale.
Better pattern:
useEffect(() => {
syncFilters(filters);
}, [filters]);
If adding a dependency causes an infinite loop, the dependency is usually unstable or the effect is doing too much. Fix the design instead of hiding the dependency.
const stableFilters = useMemo(
() => ({ status, ownerId }),
[status, ownerId]
);
useEffect(() => {
syncFilters(stableFilters);
}, [stableFilters]);
Use memoization when it protects a real contract. Do not wrap every object and function automatically. The goal is predictable synchronization, not a quiet linter.
Use cleanup for subscriptions, timers, and requests
Good React effect cleanup patterns prevent duplicate listeners, orphaned timers, and state updates after a component is gone. This matters more in React Strict Mode because effects can mount, clean up, and mount again during development to reveal unsafe assumptions.
"use client";
import { useEffect, useState } from "react";
export function OnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateStatus() {
setIsOnline(navigator.onLine);
}
updateStatus();
window.addEventListener("online", updateStatus);
window.addEventListener("offline", updateStatus);
return () => {
window.removeEventListener("online", updateStatus);
window.removeEventListener("offline", updateStatus);
};
}, []);
return <span>{isOnline ? "Online" : "Offline"}</span>;
}
The cleanup mirrors the setup. That is the simplest review rule: every subscription, listener, timer, or external resource created by an effect should have an obvious cleanup path.
For requests, prefer AbortController over a loose cancelled boolean when the API supports it:
useEffect(() => {
const controller = new AbortController();
async function loadProfile() {
const response = await fetch(`/api/profile/${userId}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error("Failed to load profile");
const data = await response.json();
setProfile(data.profile);
}
loadProfile().catch((error) => {
if (error.name !== "AbortError") {
setError("Could not load profile");
}
});
return () => {
controller.abort();
};
}, [userId]);
This keeps navigation, tab changes, and rapid user switching from leaving old requests alive.
Be careful with useEffect data fetching in Next.js
The biggest useEffect data fetching Next.js mistake is treating every request as client-only. In the App Router, many reads should happen in Server Components:
// app/projects/page.tsx
import { ProjectsTable } from "@/components/ProjectsTable";
import { getProjects } from "@/lib/projects";
export default async function ProjectsPage() {
const projects = await getProjects();
return <ProjectsTable projects={projects} />;
}
That route can render useful HTML on the first response. It can also keep secrets, database access, and authorization checks on the server. A client-side effect fetch usually gives you a blank initial render, more loading state, and more room for inconsistent cache rules.
Client-side effect fetching is still valid when the data truly depends on browser-only state:
- geolocation after user permission
- viewport-specific measurements
- browser extension or embedded widget APIs
- short-lived client-only polling
- optional progressive enhancement after the server page is already useful
When remote data is central to an interactive client surface, consider TanStack Query instead of repeating loading, retry, dedupe, and invalidation logic in every effect. The pattern in TanStack Query with Next.js App Router: Server State Without useEffect is built for that exact case.
Avoid hydration drift from client-only values
Effects run after the browser paints the initial UI. That means anything you load inside an effect cannot be part of the first server-rendered HTML. This is fine for progressive enhancement, but it can be a problem when the initial UI depends on browser-only values.
"use client";
import { useEffect, useState } from "react";
export function ThemeLabel() {
const [theme, setTheme] = useState<"light" | "dark" | null>(null);
useEffect(() => {
const storedTheme = window.localStorage.getItem("theme");
setTheme(storedTheme === "dark" ? "dark" : "light");
}, []);
if (!theme) return <span>Loading preference...</span>;
return <span>Theme: {theme}</span>;
}
The mounted-state pattern is reasonable for small client-only preferences. But if the preference changes the whole page layout, you should move the decision earlier, often through cookies, headers, or a server-readable setting. React Hydration Mismatch in Next.js: Causes, Fixes, and Prevention covers those tradeoffs more deeply.
Keep event logic out of effects
Another common smell is using state as a message bus just to trigger an effect.
Weak pattern:
const [shouldSave, setShouldSave] = useState(false);
useEffect(() => {
if (!shouldSave) return;
saveDraft();
setShouldSave(false);
}, [shouldSave]);
Better pattern:
async function handleSave() {
await saveDraft();
}
return <button onClick={handleSave}>Save draft</button>;
If the action is caused by an event, put the action in the event handler. Effects should synchronize with outside systems because rendering happened, not replace straightforward command handlers.
Build a review checklist for effects
Use this checklist during code review:
- Does this effect synchronize with something outside React?
- Can this value be derived during render instead?
- Can this data be fetched in a Server Component instead?
- Is every dependency included and stable enough?
- Does setup have matching cleanup?
- Is the first render still useful before the effect runs?
- Would a server-state cache or a smaller client boundary be clearer?
This checklist catches most effect bugs before they become production behavior.
Final takeaway
The best React useEffect best practices are less about memorizing hook tricks and more about choosing the right boundary. Use effects for browser APIs, subscriptions, imperative integrations, and narrow client-only synchronization. Avoid them for derived state, routine event logic, and server data that belongs in the App Router.
When an effect remains necessary, keep it focused, make the dependency array honest, and clean up every external resource it creates. That gives you safer React effect cleanup patterns, fewer hydration surprises, and a Next.js codebase that does not depend on useEffect for work the server or a dedicated data layer should already own.