Skip to main content
React Activity in Next.js 16: How We Handle Hidden Routes, Re-running Effects, and useEffectEvent

React Activity in Next.js 16: How We Handle Hidden Routes, Re-running Effects, and useEffectEvent

October 28, 2026
Frontend Engineering
8 min read

Key takeaways

React's Activity component, stable since React 19.2, keeps a subtree's React state and DOM but hides it with display: none and runs every Effect cleanup inside it, as if the subtree had unmounted.

Next.js 16 with cacheComponents: true hides routes in Activity instead of unmounting them and preserves up to 3 routes; the oldest is evicted and renders fresh.

DOM side effects survive hiding: video, audio and iframe elements, page-level style tags and open menus need an explicit reset in a useLayoutEffect cleanup or a callback-ref cleanup.

Effects re-run on every hide-and-show cycle, so useEffectEvent keeps non-reactive values out of dependency arrays without extra reconnects.

Hidden Activity content stays in the document, so end-to-end tests need visibility-aware queries: Playwright's getByRole skips hidden elements, a bare CSS locator does not.

What does React's Activity component actually do?

Activity is a React component, released in React 19.2 on October 1, 2025, that hides and restores part of the component tree without unmounting it. Its only prop besides children is mode, which is either 'visible' (the default) or 'hidden'.

A hidden Activity boundary sets display: none on its children, runs the cleanup of every Effect inside it, and keeps re-rendering the children at a lower priority when their props change. Switching back to 'visible' reveals the same DOM with the same state and runs each Effect's setup again.

React state and refs: discarded by conditional rendering, kept by a hidden Activity.

DOM state such as input values, scroll position, open details elements and media playback time: discarded by conditional rendering, kept by a hidden Activity.

Effects: cleaned up in both cases; a hidden Activity re-creates them when it becomes visible again.

Re-renders on prop changes: none after unmounting; a hidden Activity keeps re-rendering at lower priority.

Why does a Next.js 16 page keep its state after navigation?

A Next.js 16 page keeps its state after navigation when cacheComponents: true is set in next.config.ts, because Next.js then hides the previous route in an Activity boundary instead of unmounting it. Next.js preserves up to 3 routes this way; beyond that, the oldest route is evicted and renders fresh on its next visit. Without Cache Components, the App Router still unmounts pages on navigation.

Before Cache Components, our team kept filter panels and drafts alive across navigation by hoisting state into a shared layout or an external store. Now preservation is the default, so every piece of page state becomes a decision: search filters and drafts should survive, while an open dropdown, a 'Message sent!' banner or state that belonged to the previous signed-in user should not.

For user changes, we key user-scoped forms with the user ID and log out with a full reload through window.location.href instead of router.push(). For pages that must start fresh on every link click, the Next.js 16.3 docs add useRouter().bfcacheId: used as the key of a Fragment, it resets the subtree on Link clicks, router.push() and router.replace() while still restoring state on browser Back and Forward. The docs describe it as mainly a migration tool.

What breaks when a page is hidden instead of unmounted?

A hidden component behaves like an unmounted one for its Effects and like a mounted one for its DOM, so anything the DOM does on its own keeps happening. These are the cases we check on every page that can be preserved.

Media: video, audio and iframe elements keep playing under display: none; unmounting used to stop them for free.

Transient open state: a dropdown or popover that was open when the user left is still open when they come back.

Page-level styles: a style tag that sets CSS variables or z-index on :root keeps applying to whichever page is visible.

Mount-only logic: an Effect that focuses an input when isDialogOpen becomes true does not fire again if isDialogOpen was preserved as true. The Next.js guide fixes this by deriving dialog state from a search param.

Duplicate IDs: a hard-coded id in a component rendered on two preserved routes now exists twice in one document, and document.getElementById() and label htmlFor both resolve to the first match in tree order, which can be the hidden copy. React's useId() avoids the collision.

The fix for media, open state and styles is the same: reset them in a cleanup function, which now runs on hide as well as on unmount. The React and Next.js docs both use useLayoutEffect for this, because its cleanup runs synchronously as the UI is hidden, while a regular Effect's cleanup can be delayed by a re-suspending Suspense boundary or a View Transition. In a lesson player, for example, one useLayoutEffect cleanup calls video.pause() and closes the playback-speed menu; the video element keeps its currentTime because the DOM node is never destroyed.

Callback refs follow the same lifecycle. Since React 19 a ref callback can return a cleanup function, and Activity runs it on hide; the Next.js guide uses this to disable a page's style tag by setting its media attribute to 'not all' and to re-enable it with an empty string.

Where does useEffectEvent fit when Effects re-run on every show?

useEffectEvent is a React Hook, stable since React 19.2, that wraps logic fired from an Effect so it always reads the latest props and state without becoming an Effect dependency. It matters more under Activity because every hide-and-show cycle tears down and re-creates each subscription, and an Effect that lists UI preferences as dependencies also reconnects whenever one of those preferences changes.

Our clearest case is a live order feed: one WebSocket per store, plus a sound preference and a status filter that decide whether a new order plays a chime. Only storeId should reconnect the socket. We wrap the message handler in useEffectEvent so it reads the latest sound and filter values, and the Effect that opens the socket lists only storeId as a dependency. While the page is hidden, the cleanup closes the socket, so a preserved route holds no live connection; when the page becomes visible, the Effect reconnects once.

The rules, enforced by eslint-plugin-react-hooks v6 and later: call an Effect Event only inside useEffect, useLayoutEffect, useInsertionEffect or another Effect Event in the same component, never during render; do not pass it to child components or other Hooks; and never list it in a dependency array. Its identity changes on every render on purpose, so depending on it makes the mistake visible.

useEffectEvent is not a way to silence the linter. If a new pageUrl should log a new page view, pageUrl stays in the dependency array. For 'first mount only' logic such as a one-time impression event, we use a useRef(false) flag, because refs are not reset when Activity hides a subtree.

Can the Activity component load a tab before the user opens it?

Yes. A boundary that starts in hidden mode still renders its children at low priority without mounting their Effects, so code, data, CSS and images can load before the content is shown. Only data read through Suspense, such as a promise unwrapped with use(), is fetched during that pre-render; a fetch() inside useEffect waits until the boundary becomes visible.

Our pattern is a Server Component that starts the query and passes the unresolved promise to a client component, which wraps the content in Activity and Suspense and reads the promise with use(). Activity boundaries also take part in Selective Hydration like Suspense boundaries, so React can make the rest of the page interactive before hydrating a heavy subtree. We still avoid wrapping every tab in Activity: hidden subtrees keep their DOM in memory and re-render when their props change.

How do we keep Playwright from matching hidden Activity content?

We use visibility-aware queries, because hidden Activity content stays in the document with display: none and plain CSS selectors still match it. Playwright's getByRole, getByLabel and getByPlaceholder skip hidden elements; page.locator('.product-card').first() can return a card from a route the user already left, so our CSS locators get an explicit filter({ visible: true }).

For Effects without proper cleanup, Strict Mode is the cheapest check: the React docs note that it eagerly performs Activity unmounts and mounts in development, which surfaces Effects that set something up and never tear it down.

FAQ

Q: Is a hidden Activity the same as hiding a component with CSS?

A: No. CSS display: none alone leaves Effects running, so timers, subscriptions and sockets stay active. A hidden Activity boundary also runs every Effect cleanup and lowers the render priority of the subtree, while keeping its state and DOM.

Q: Does Next.js 16 keep every visited page alive?

A: No. Activity-based route preservation applies only when cacheComponents: true is enabled, and Next.js keeps at most 3 routes; the oldest is evicted and renders fresh on its next visit.

Q: Should useEffectEvent replace the 'latest value in a ref' pattern?

A: For callbacks fired from your own Effects, yes: useEffectEvent does the same job and the React Hooks linter understands it. Keep a ref only when the callback has to be passed to another component or Hook, which Effect Events do not allow.

Q: Do components inside a hidden Activity still re-render?

A: Yes. When a hidden boundary's props change, React re-renders its children at a lower priority than visible content, but their Effects stay unmounted until the boundary becomes visible again.

Q: Can screen readers reach hidden Activity content?

A: No. display: none removes hidden Activity content from the accessibility tree. Hard-coded id values can still collide with the visible page, so labels and ARIA references should use IDs from useId().