Next.js App Router: Fix useEffect Not Running on Route Change
When a dynamic segment like /posts/[id] changes, the Next.js App Router reuses the same page component instance, so a useEffect with an empty dependency array never re-runs. To fix this, read the param with useParams() (or accept it via props) and put it in the dependency array so React re-runs the effect when the segment changes.
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
export default function PostClient() {
const { id } = useParams<{ id: string }>();
const [post, setPost] = useState<Post | null>(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/posts/${id}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setPost(data);
});
// Cancel stale responses when id changes mid-flight.
return () => {
cancelled = true;
};
}, [id]);
return <article>{post?.title}</article>;
}
Why the Effect Does Not Re-Run by Default
In the App Router, navigating from /posts/1 to /posts/2 does not unmount the page. React sees the same component tree and reconciles it, so any effect with [] only runs once on the initial mount. This differs from the Pages Router, where people often ran effects based on router.query. The same principle applies here, but you need to explicitly declare the param as a dependency.
Server Component Version
If you only need the data once per navigation and do not need client interactivity, skip useEffect entirely. Fetch in the server component and Next.js re-renders it on every param change for free. This is the preferred approach for read-only data.
// app/posts/[id]/page.tsx
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const res = await fetch(`https://api.example.com/posts/${id}`, {
// Opt into per-request revalidation as needed.
next: { revalidate: 60 },
});
const post = await res.json();
return <article>{post.title}</article>;
}
When useParams Returns Stale Values
Here's a common gotcha. If you read the param from a parent layout and pass it down as a prop, effects in the child do not re-run unless the prop actually changes identity. Always destructure primitives like id from useParams() directly in the component that owns the effect, not from a memoized object. Passing params as an object means every render creates a new reference and the effect fires too often. Passing a primitive string means it fires exactly when it should.
Forcing a Full Remount With the Key Prop
If you have complex client state that should reset completely between posts (form inputs, scroll position, third-party widgets), the cleanest fix is to key the component by the dynamic segment. React unmounts the old tree and mounts a fresh one, which runs every mount effect again without you touching dependency arrays.
// app/posts/[id]/page.tsx
import PostClient from './PostClient';
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// The key forces PostClient to remount when id changes.
return <PostClient key={id} id={id} />;
}
Race Conditions on Fast Navigation
Clicking through /posts/1, /posts/2, and /posts/3 quickly can cause the response for post 1 to resolve after post 3, overwriting the correct data. The cancelled flag in the first example handles this, but for anything non-trivial, use AbortController so the in-flight request actually terminates instead of being ignored.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/posts/${id}`, { signal: controller.signal })
.then((res) => res.json())
.then(setPost)
.catch((err) => {
// Ignore aborts, rethrow real errors.
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [id]);
Do Not Use usePathname as a Proxy
One pattern looks like it works but is fragile: depending on usePathname() instead of the actual param. It fires on every path change, including query string updates and unrelated segment shifts, which causes extra fetches. Stick to the specific param you care about. If you genuinely need to react to search params, use useSearchParams() and depend on the specific key you read.
Prefer a Data Library Over Raw useEffect
If you find yourself writing the same fetch-cancel-dependency dance in several places, switch to SWR or TanStack Query. Both handle param-keyed caching, cancellation, and revalidation for you, and they compose cleanly with the App Router. The useEffect approach works for one-off cases, but it becomes a maintenance burden after you add deduplication or background refetching requirements.
'use client';
import useSWR from 'swr';
import { useParams } from 'next/navigation';
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function PostClient() {
const { id } = useParams<{ id: string }>();
// SWR keys the cache by URL, so changing id refetches automatically.
const { data, isLoading } = useSWR(`/api/posts/${id}`, fetcher);
if (isLoading) return <p>Loading...</p>;
return <article>{data.title}</article>;
}