
Next.js Preserving UI state with Activity
Before Cache Components, keeping a page's state alive across a navigation and back required real effort: hoisting state up into a shared layout so it wouldn't unmount, or reaching for an external store like Zustand or Redux purely to survive route changes React would otherwise blow away. Both are workarounds for the same underlying fact — navigating away from a page in the App Router used to mean unmounting it, and unmounting means every useState, every scroll position, every expanded <details> element, gone.
With Cache Components enabled, that's no longer the default. Next.js now hides pages instead of unmounting them, using React's <Activity> component, and the result is state preservation across navigation entirely for free — no shared layout gymnastics, no external store required just to survive a back button press. This article covers how that actually works, and — more importantly — the patterns for the cases where you don't want the old page's state hanging around.
This entire guide assumes Cache Components is enabled (cacheComponents: true in your config) — none of what follows applies without it.
What <Activity> actually does
Instead of unmounting a page you've navigated away from, Next.js hides it with React's <Activity> component — keeping the DOM node in the document, just hidden via display: none. Because the DOM node never actually leaves, both React state and DOM state survive: form drafts mid-typing, scroll position, which <details> elements are expanded, even a video's playback progress, all still there exactly as the user left them if they navigate back.
There's a bound on how much this preserves: Next.js keeps up to 3 routes this way. Beyond that, the oldest gets evicted and re-renders fresh on the next visit — this isn't unlimited memory growth as a user navigates around a large app, it's a small, fixed window.
One migration-specific tool worth knowing about but not over-relying on: useRouter().bfcacheId, used as a React key on a single wrapping <Fragment>, resets an entire subtree's state on push/replace navigations (including plain <Link> clicks) while still correctly restoring state on browser back/forward. The docs are explicit that this is primarily a migration aid for getting an existing app onto Cache Components without immediately auditing every component for the new preservation behavior — for genuinely new code, the narrower, per-pattern resets below are the better long-term approach, since they let you decide preservation deliberately per piece of UI rather than nuking an entire subtree's state indiscriminately.
The core judgment call: keep it, or reset it?
Activity preserves everything by default. Your job, component by component, is deciding whether that default is actually what you want — and the honest answer varies a lot by what kind of UI element you're looking at.
Expandable UI: dropdowns vs. persistent panels
Keep it for state the user deliberately configured — a sidebar's expanded sections, a FAQ accordion, a filters panel they set up intentionally. Restoring exactly that configuration on return is a genuine UX win; re-doing that setup work every time you navigate back would be actively annoying.
Reset it for transient, click-triggered UI — a dropdown menu, a popover. These aren't persistent view configuration; they're momentary interactions, and a dropdown silently still being open when a user navigates back is confusing rather than helpful.
The fix for the reset case is a useLayoutEffect cleanup, which runs synchronously when Activity hides the component:
"use client";
import { useState, useLayoutEffect } from "react";
function SettingsDropdown() {
const [isOpen, setIsOpen] = useState(false);
useLayoutEffect(() => {
return () => {
setIsOpen(false);
};
}, []);
return (
<div>
<button onClick={() => setIsOpen((o) => !o)}>Options</button>
{isOpen && (
<ul>
<li>
<button>Edit Profile</button>
</li>
<li>
<button>Change Password</button>
</li>
</ul>
)}
</div>
);
}
useLayoutEffect specifically, not useEffect, matters here — it runs synchronously before the component is actually hidden, which avoids any flash of the stale open state being briefly visible during the hide transition. <Link>'s onNavigate callback is a second option worth knowing about if you'd rather close a dropdown at the moment a navigation link is clicked, rather than reactively in a cleanup.
Dialogs with initialization logic
This is a genuinely subtle failure mode worth understanding precisely, because it doesn't error — it just silently doesn't do what you'd expect. Consider a dialog whose open state triggers a focus effect:
"use client";
import { useState, useRef, useEffect } from "react";
function ProductTab() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isDialogOpen) {
inputRef.current?.focus();
}
}, [isDialogOpen]);
// ...
}
If a user navigates away while this dialog is open, Activity preserves isDialogOpen: true. Navigate back and reopen the dialog — except isDialogOpen was already true, so setting it to true again produces no state change at all, and the focus Effect simply never re-fires. The dialog appears open, but the input never gets focused, and there's no error anywhere to point you at why.
The fix is deriving dialog state from something outside the preserved component state entirely — a search param is the natural choice, since the URL genuinely does change (or clear) on navigation in a way local state doesn't:
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
function ProductTab() {
const searchParams = useSearchParams();
const router = useRouter();
const isDialogOpen = searchParams.get("edit") === "true";
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isDialogOpen) {
inputRef.current?.focus();
}
}, [isDialogOpen]);
return (
<div>
<button onClick={() => router.push("?edit=true")}>Edit Product</button>
{isDialogOpen && (
<dialog open>
<input ref={inputRef} placeholder="Product name" />
<button onClick={() => router.replace("?", { scroll: false })}>
Close
</button>
</dialog>
)}
</div>
);
}
Navigating away clears the search param — the URL genuinely changed — so isDialogOpen becomes false on return, and reopening the dialog sets the param again, which does change isDialogOpen and correctly re-triggers the focus effect.
Forms: the biggest win, and its sharpest edge case
Preserved form input across navigation — text fields, selections, checkbox states — is arguably the single largest practical UX benefit of this whole feature. A user filling out a long form, clicking away to check something, and coming back to find their draft untouched is a genuine, felt improvement.
But it cuts the other way for flows that should start clean. A "create new item" form, after successful submission, needs deliberate resetting — otherwise navigating back to it shows the just-submitted values still sitting there:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function NewItemPage() {
const [name, setName] = useState("");
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const item = await createItem({ name });
setName("");
router.push(`/items/${item.id}`);
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit">Create</button>
</form>
);
}
Resetting name right in the submit handler works cleanly here because submission is the one reliable, user-initiated event you control. Status messages are trickier — there's often no reliable user-initiated moment to clear a stale "Message sent!" confirmation, because the user might leave via a <Link> you don't control, or the browser's own back button, neither of which fires your handler:
"use client";
import { useState, useRef, useLayoutEffect } from "react";
function ContactForm() {
const [name, setName] = useState("");
const [status, setStatus] = useState<"idle" | "success">("idle");
const shouldReset = useRef(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await sendMessage({ name });
setStatus("success");
shouldReset.current = true;
}
useLayoutEffect(() => {
return () => {
if (shouldReset.current) {
shouldReset.current = false;
setStatus("idle");
setName("");
}
};
}, []);
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit">Send</button>
{status === "success" && <p>Message sent!</p>}
</form>
);
}
The shouldReset ref is the detail that makes this correct rather than merely plausible-looking: it ensures the cleanup only actually resets state after a genuine successful submission, not on every hide. A user who navigates away mid-draft, having never submitted, keeps their in-progress input intact — only the post-submission success state gets cleared. If you're using useActionState, React's own docs cover adding an explicit RESET action to your reducer for the same purpose.
State and authentication: a trap worth naming directly
Activity preserves local component state across navigations even when the authenticated user changes — this is genuinely standard React behavior (new props don't reset existing state), but it has a sharp edge in a multi-user context: a draft composed under one session shouldn't remain visible after a different user logs in on the same browser tab.
If your logout flow uses window.location.href instead of router.push, you get a full page reload, which clears all client-state by definition — the simplest fix, when applicable. If you need finer control without a full reload, watch for the user ID changing and reset explicitly:
"use client";
import { useState, useEffect, useRef } from "react";
function UserScopedForm({ userId }: { userId: string | null }) {
const [draft, setDraft] = useState("");
const lastUserIdRef = useRef<string | null>(null);
useEffect(() => {
if (lastUserIdRef.current !== null && lastUserIdRef.current !== userId) {
setDraft("");
}
lastUserIdRef.current = userId;
}, [userId]);
return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}
Or, more simply, key the component itself by user ID and let React's own reconciliation handle the reset: <Form key={userId} />.
Global styles need explicit toggling
Page-level CSS — variables, z-index overrides, global classes — can leak into a visible page from a component Activity has merely hidden, not removed, since the hidden element's styles are still technically part of the document. A callback ref toggling the stylesheet's media attribute is the pattern for scoping this correctly:
<style
ref={(style) => {
if (style) style.media = "";
return () => {
if (style) style.media = "not all";
};
}}
>
{`:root { --page-accent: blue; }`}
</style>
For anything with more complex cleanup needs, useLayoutEffect managing multiple style elements works the same way. Worth a specific callout on CSS :has() selectors here: a broad :root:has(...) rule bypasses React's data flow entirely and couples components that shouldn't know about each other — prefer a data-* attribute React actually owns (<html data-modal-open="true">) for anything meant to reflect global state, and reserve :has() for genuinely local parent/child styling within one component's own markup. This isn't just a React-specific concern either — broad :has() selectors are a documented browser performance cost independent of any of this.
Testing: hidden content is still in the DOM
This is the detail most likely to break an existing end-to-end test suite silently after adopting Cache Components: Activity-hidden content has display: none, but it's still present in the document — which means naive DOM queries can find it, interactions with it will fail or hang waiting for visibility, and loose assertions can match content the user can't actually see.
The fix is using visibility-aware selectors rather than raw ones. In Playwright, getByRole, getByLabel, and getByPlaceholder all filter by visibility automatically, because they query the accessibility tree, which excludes hidden elements by construction:
// Good — filters by visibility automatically
await page.getByRole("button", { name: "Submit" }).click();
// Avoid — may match a hidden element sitting in an Activity boundary
await page.locator(".product-card").first().click();
When getByRole doesn't fit, .locator().filter({ visible: true }) is the explicit fallback. Cypress has its own equivalent via .should('be.visible') or a { visible: true } option. Whatever tool you're using, check specifically for a visibility-aware query mechanism before assuming your existing selectors still behave correctly under Activity.
Using <Activity> directly, beyond routes
Cache Components applies Activity automatically at the route level, but nothing stops you from reaching for it directly in your own components — tabs, expandable panels, anything you want to hide without unmounting.
One genuinely clever pattern this unlocks: prerendering content the user hasn't asked to see yet, at lower priority, so it's ready the instant they do ask. A Server Component starts a data fetch immediately and hands the promise down; the Client Component wraps the eventual UI in <Activity mode="hidden"> until requested, and resolves the promise with use() once actually shown:
// app/expandable-comments.tsx
"use client";
import { Activity, Suspense, useState, use } from "react";
export function ExpandableComments({
commentsPromise,
}: {
commentsPromise: Promise<Comment[]>;
}) {
const [expanded, setExpanded] = useState(false);
return (
<>
<button onClick={() => setExpanded((e) => !e)}>
{expanded ? "Hide Comments" : "Show Comments"}
</button>
<Activity mode={expanded ? "visible" : "hidden"}>
<Suspense fallback={<CommentsSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</Activity>
</>
);
}
While hidden, the data streams in at lower priority in the background. Click "Show Comments" and — if the fetch already resolved during that idle window — the content appears instantly, with no visible loading state at all.
Effects, media, and the mount/re-show distinction
Activity runs the same cleanup functions on hide that React runs on unmount, which means timers and subscriptions with correct cleanup pause automatically:
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id); // Pauses when hidden
}, []);
<video> and <audio> are a specific exception worth knowing: display: none alone does not stop playback — you need an explicit useLayoutEffect cleanup calling .pause(), or a hidden video keeps playing silently, off-screen, burning resources and potentially audio.
And because Activity re-triggers effects on every hide-to-visible transition — not just the true initial mount — code that needs to distinguish "first time this rendered" from "user navigated back to it" needs an explicit ref-based guard:
const hasMountedRef = useRef(false);
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
console.log("First mount");
} else {
console.log("Became visible again");
}
}, []);
Key Takeaways
| Situation | Default behavior | What to do if you want the opposite |
|---|---|---|
| Persistent panels (filters, expanded sidebar sections) | Preserved | Nothing — this is the desired case |
| Transient popovers/dropdowns | Preserved | Close in a useLayoutEffect cleanup |
| Dialog with focus-on-open logic | State preserved, effect doesn't re-fire | Derive open state from a search param, not local state |
| In-progress form drafts | Preserved | Nothing — this is usually the desired case |
| Post-submission success messages | Preserved (goes stale) | Reset via a guarded useLayoutEffect cleanup |
| State across a user/auth change | Preserved | Explicit reset on user-ID change, or key the component by user ID |
Timers, subscriptions with useEffect cleanup | Paused automatically | Nothing — cleanup already handles it |
<video>/<audio> playback | Keeps playing while hidden | Explicit useLayoutEffect calling .pause() |
| End-to-end test selectors | Hidden elements still queryable | Use visibility-aware queries (getByRole, { visible: true }) |
State preservation with Activity is one of those features that's simultaneously a large, unambiguous UX win and a source of genuinely subtle bugs if you assume the old unmount-based mental model still applies. The pattern that recurs across almost every fix above is the same one: decide, deliberately, whether a given piece of UI represents something the user meant to configure (keep it) or a transient interaction that shouldn't outlive its moment (reset it) — and reach for useLayoutEffect cleanup, or a URL-derived state source, whichever fits the specific case.


