Repository: vercel/next.js
Pull request: feat: Join guides and frame around UI preservation

As we discussed, let's scale back how much Activity we teach and focus on UI preservation, choice of pattern, and wrap it all with a demo where these are implemented.

- [x] rename file

Changed files:
filename: docs/01-app/01-getting-started/06-cache-components.mdx
patch: @@ -554,7 +554,7 @@ This behavior improves the navigation experience by maintaining UI state (form i
 
 > **Good to know**: Next.js uses heuristics to keep a few recently visited routes `"hidden"`, while older routes are removed from the DOM to prevent excessive growth.
 
-Some UI patterns behave differently when components stay mounted instead of unmounting. See the [Activity with Cache Components guide](/docs/app/guides/activity-cache-components) for handling common patterns like dropdowns, dialogs, and testing.
+Some UI patterns behave differently when components stay mounted instead of unmounting. See [Preserving UI state across navigations](/docs/app/guides/preserving-ui-state) for handling common patterns like dropdowns, dialogs, and testing.
 
 ## Migrating route segment configs
 

filename: docs/01-app/02-guides/activity-cache-components.mdx
patch: @@ -1,313 +0,0 @@
----
-title: Activity with Cache Components
-nav_title: Activity with Cache Components
-description: Learn how to decide which UI state to preserve and which to reset when Cache Components keeps routes mounted with Activity.
-related:
-  title: Related
-  description: Learn more about Activity and Cache Components.
-  links:
-    - app/getting-started/cache-components
-    - app/guides/activity
----
-
-When [Cache Components](/docs/app/getting-started/cache-components) is enabled, routes don't unmount when you navigate away from them. Instead, Next.js uses React's [`<Activity>`](https://react.dev/reference/react/Activity) component to hide them - setting them to `mode="hidden"` rather than removing them from the tree.
-
-This preserves component state and DOM state, which is great for back/forward navigation. But patterns that relied on unmounting to clear state will no longer reset automatically. You decide what to keep and what to reset.
-
-Next.js uses a **3-entry limit** - it preserves up to 3 routes. When you navigate beyond that, the oldest route's DOM and React state are dropped. This means if a user navigates through many pages and then goes back, very old states may have been evicted and will re-render fresh.
-
-### Why Activity ships with Cache Components
-
-Cache Components and Activity work together to enable SPA-like navigation - instant route transitions without sacrificing server rendering:
-
-- **Server Components** use `"use cache"` to extend their lifetime, enabling prefetching and instant route transitions
-- **Client Components** use Activity to preserve their state and DOM across navigations - without it, form inputs, scroll positions, and component state would reset on every navigation
-
-This foundation enables full route prefetching, instant back/forward navigation, and upcoming features like View Transitions and Gesture APIs.
-
-> **Good to know:** Opt-out strategies are being considered for gradual migration.
-
-If you want to understand how Activity works and the patterns it enables, see the [Activity guide](/docs/app/guides/activity).
-
-## Choosing what to preserve
-
-Activity preserves all component state by default. For each piece of state, you decide whether that's the right behavior for your UI. The patterns below show common scenarios and how to handle both sides.
-
-### Expandable UI (dropdowns, accordions, panels)
-
-When a user navigates away and returns, Activity preserves the open/closed state of expandable elements.
-
-**When to keep it:** A sidebar with expanded sections, a FAQ accordion, or a filters panel - the user set up their view intentionally, and restoring it avoids re-doing that work.
-
-**When to reset it:** A dropdown menu or popover triggered by a button click - these are transient interactions, not persistent view state. Returning to a page with a dropdown already open is not user friendly.
-
-To reset transient open/closed state, close it in a `useLayoutEffect` cleanup function:
-
-```tsx highlight={8-13}
-'use client'
-
-import { useState, useLayoutEffect } from 'react'
-
-function SettingsDropdown() {
-  const [isOpen, setIsOpen] = useState(false)
-
-  // Close dropdown when this component becomes hidden
-  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>
-  )
-}
-```
-
-When Activity hides this component, the cleanup function runs and resets `isOpen`. When the page becomes visible again, the dropdown is closed. Using `useLayoutEffect` ensures the cleanup runs synchronously before the component is hidden, avoiding any flash of stale state.
-
-You can also use `Link`'s [`onNavigate`](/docs/app/api-reference/components/link#onnavigate) callback to close dropdowns immediately when a navigation link is clicked.
-
-### Dialog and initialization logic
-
-Activity preserves dialog open/closed state. This also affects Effects that run based on that state.
-
-**When to keep it:** A multi-step wizard or a settings panel that the user was actively working in - preserving the step and input state avoids losing progress.
-
-**When to reset it:** A dialog that runs initialization logic (like focusing an input) each time it opens. If the user navigated away while the dialog was open, Activity preserves `isDialogOpen: true`. Opening it again sets it to `true` when it's already `true` - no state change means the Effect doesn't re-run.
-
-Consider this example:
-
-```tsx
-'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 the user navigated away while the dialog was open, returning and opening the dialog won't trigger the focus Effect because `isDialogOpen` was already `true`.
-
-To fix this, derive the dialog state from something outside the preserved component state like a search param:
-
-```tsx highlight={3,7-9,20,25}
-'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>
-  )
-}
-```
-
-With this approach, `isDialogOpen` derives from the URL rather than component state. When navigating away and returning, the search param is cleared (the URL changed), so `isDialogOpen` becomes `false`. Opening the dialog sets the param, which changes `isDialogOpen` and triggers the Effect.
-
-### Form input values
-
-Activity preserves form input values - text typed into fields, selected options, checkbox states.
-
-**When to keep it:** A search page with filters, a draft the user was composing, or a settings form with unsaved changes. Preserving input state is one of Activity's biggest UX wins - the user doesn't lose work.
-
-**When to reset it:** A "create new item" page where returning should start fresh, or a contact form after successful submission.
-
-To reset form fields when Activity hides the component, use a callback ref:
-
-```tsx
-<form
-  ref={(form) => {
-    // Cleanup function - runs when Activity hides this component
-    return () => form?.reset()
-  }}
->
-  {/* fields */}
-</form>
-```
-
-This resets the form whenever the user navigates away. See [Resetting state](/docs/app/guides/activity#resetting-state) in the Activity guide for more patterns, including key-based resets for React state.
-
-### Action state (`useActionState`)
-
-Activity preserves [`useActionState`](https://react.dev/reference/react/useActionState) results - success messages, error messages, and any other state returned by the action.
-
-**When to keep it:** A ticket redemption form showing "Ticket redeemed successfully", or a settings form showing "Changes saved". Seeing the result of a previous action when returning to the page is useful confirmation so the user can see what happened.
-
-**When to reset it:** A "new transaction" flow where each visit should start fresh, or a form where stale success/error messages would be confusing in a new context.
-
-You can think of `useActionState` as a `useReducer` that allows side effects. It doesn't have to only handle form submissions - you can dispatch any action to it. Adding a `RESET` action gives you a clean way to clear state when Activity hides the component (see [Reset state](https://react.dev/reference/react/useActionState#reset-state) in the React docs):
-
-```tsx highlight={5-6,9-21,26-35}
-'use client'
-
-import { useActionState, useLayoutEffect, useRef, startTransition } from 'react'
-
-type Action = { type: 'SUBMIT'; data: FormData } | { type: 'RESET' }
-type State = { success: boolean; error: string | null }
-
-function CommentForm() {
-  const [state, dispatch, isPending] = useActionState(
-    async (prev: State, action: Action) => {
-      if (action.type === 'RESET') {
-        return { success: false, error: null }
-      }
-      // Handle the form submission
-      const res = await saveComment(action.data)
-      if (!res.ok) return { success: false, error: res.message }
-      shouldReset.current = true
-      return { success: true, error: null }
-    },
-    { success: false, error: null }
-  )
-
-  const shouldReset = useRef(false)
-
-  // Dispatch RESET when Activity hides this component
-  useLayoutEffect(() => {
-    return () => {
-      if (shouldReset.current) {
-        shouldReset.current = false
-        startTransition(() => {
-          dispatch({ type: 'RESET' })
-        })
-      }
-    }
-  }, [dispatch])
-
-  return (
-    <form action={(formData) => dispatch({ type: 'SUBMIT', data: formData })}>
-      <textarea name="comment" />
-      <button type="submit" disabled={isPending}>
-        {isPending ? 'Posting...' : 'Post Comment'}
-      </button>
-      {state.success && <p>Comment posted!</p>}
-      {state.error && <p>{state.error}</p>}
-    </form>
-  )
-}
-```
-
-Here's what happens step by step:
-
-1. The user submits the form. The reducer receives a `SUBMIT` action with the `FormData`, calls `saveComment`, and returns `{ success: true }`. It also sets `shouldReset.current = true` to mark that a reset is needed.
-2. The user navigates away. Activity hides the component and runs the `useLayoutEffect` cleanup. Because `shouldReset.current` is `true`, it dispatches a `RESET` action.
-3. The reducer receives `RESET` and returns the initial state (`{ success: false, error: null }`). The stale success message is cleared.
-4. If the user navigates back, the form is ready for a new submission. If they never submitted (step 1 didn't happen), `shouldReset.current` is still `false`, so no `RESET` is dispatched. The form stays as-is.
-
-## State and authentication
-
-Activity preserves local component state (`useState`, DOM input values) across navigations, including authentication changes. This is standard React behavior: props changing (such as receiving a new user) triggers a re-render but does not reset existing state. A draft composed by one user shouldn't be visible to another.
-
-For logout flows, using `window.location.href` instead of `router.push` triggers a full page reload, clearing all client-side state.
-
-To reset specific state when the user changes without a full reload:
-
-```tsx
-'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('') // Reset on user change
-    }
-    lastUserIdRef.current = userId
-  }, [userId])
-
-  return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
-}
-```
-
-Alternatively, key components by user ID to let React handle the reset: `<Form key={userId} />`. See [Resetting state](/docs/app/guides/activity#resetting-state) for more patterns.
-
-## Global styles
-
-Page-level styles (CSS variables, z-index, global classes) can affect visible pages when the originating component is hidden by Activity. You likely want to disable them when hidden: a hidden page's accent color or z-index overrides shouldn't leak into the visible page.
-
-Use a callback ref to toggle the stylesheet's `media` attribute:
-
-```tsx
-<style
-  ref={(style) => {
-    if (style) style.media = '' // Enable when visible
-    return () => {
-      if (style) style.media = 'not all' // Disable when hidden
-    }
-  }}
->
-  {`:root { --page-accent: blue; }`}
-</style>
-```
-
-Or use `useLayoutEffect` when managing multiple style elements or more complex cleanup:
-
-```tsx
-'use client'
-
-import { useLayoutEffect, useRef } from 'react'
-
-function PageWithStyles() {
-  const styleRef = useRef<HTMLStyleElement>(null)
-
-  useLayoutEffect(() => {
-    if (styleRef.current) styleRef.current.media = ''
-    return () => {
-      if (styleRef.current) styleRef.current.media = 'not all'
-    }
-  }, [])
-
-  return <style ref={styleRef}>{`:root { --page-accent: blue; }`}</style>
-}
-```
-
-When Activity hides the component, the cleanup sets `media="not all"`, which disables the stylesheet. When visible again, the effect re-runs and resets `media` to enable it.

filename: docs/01-app/02-guides/activity.mdx
patch: @@ -1,604 +0,0 @@
----
-title: How to use the Activity component in Next.js
-nav_title: Activity
-description: Learn how to use Activity in Next.js to preserve React and DOM state across hide/show cycles, enabling patterns like tabs, collapsible panels, and prerendering.
-related:
-  title: Related
-  description: Activity works with Cache Components to enable instant navigation with preserved state.
-  links:
-    - app/getting-started/cache-components
-    - app/api-reference/config/next-config-js/cacheComponents
----
-
-You can find the resources used in this guide here:
-
-- [Demo](https://react-activity-patterns.vercel.app/)
-- [Code](https://github.com/vercel-labs/react-activity-patterns)
-
-## Background
-
-React 19.2 introduced the [`Activity`](https://react.dev/reference/react/Activity) component - a powerful primitive that allows developers to persist DOM elements and React state while visually hiding parts of the UI.
-
-> **Good to know:** Activity in the App Router is available in **Next.js 16** and later, which includes React 19.2. See [Installation](/docs/app/getting-started/installation) for details on how Next.js manages React versions.
-
-## How Activity works
-
-Activity acts as a boundary, with two modes, `hidden` or `visible` (default). When set to `mode="hidden"`, it:
-
-- Applies `display: none` to all children (DOM remains in the document)
-- **Cleans up Effects** (runs cleanup functions)
-- **Runs ref cleanup functions** (callback refs that return a cleanup function)
-- Preserves React state and memoized values
-- Re-renders in response to new props or context at **lower priority**
-- **Skipped during SSR** - hidden content is not in the initial HTML payload, but React renders it client-side and adds it to the DOM
-
-When the boundary becomes `mode="visible"` again, React reveals the children with their previous state restored and re-creates their Effects.
-
-```tsx
-'use client'
-
-import { Activity, useState } from 'react'
-
-function Dashboard() {
-  const [isVisible, setIsVisible] = useState(true)
-  const toggleSidebar = () => setIsVisible((state) => !state)
-
-  return (
-    <>
-      <Activity mode={isVisible ? 'visible' : 'hidden'}>
-        <Sidebar />
-      </Activity>
-      <Charts toggleSidebar={toggleSidebar} />
-    </>
-  )
-}
-```
-
-When `isVisible` is `false`, the `Sidebar` is hidden but its DOM and React state remain intact. It still re-renders in response to new props or context changes, but at a lower priority than visible content. Toggling it back to `true` instantly reveals the sidebar in its previous state - scroll position, expanded sections, and any other internal state are all preserved.
-
-### Why use Activity over a custom wrapper
-
-You might wonder why not just wrap content in a `<div style={{ display: 'none' }}>`. Activity provides several advantages:
-
-- **No extra DOM element**: A wrapper div can break valid HTML (e.g., inside `<ul>`), CSS layouts, and accessibility tooling
-- **Effect lifecycle management**: React knows to clean up and restore Effects appropriately
-- **Portals are also hidden**: Content rendered via portals (modals, tooltips) is correctly hidden along with the Activity
-- **Selective Hydration**: Activity boundaries participate in React's hydration optimization
-- **Future modes**: The API is designed to support additional modes beyond visible/hidden
-
-## Persisting state
-
-### DOM-managed state
-
-Many HTML elements manage their own internal state: `<input>` and `<textarea>` values, `<details>` open/closed state, scroll position, video playback timecode, etc.
-
-Consider a filters panel with expandable sections:
-
-```tsx
-'use client'
-
-function FiltersPanel() {
-  return (
-    <aside>
-      <details>
-        <summary>Price Range</summary>
-        <input type="range" min="0" max="1000" />
-      </details>
-
-      <details open>
-        <summary>Categories</summary>
-        <input type="text" placeholder="Search categories..." />
-        {/* ... checkbox list */}
-      </details>
-    </aside>
-  )
-}
-```
-
-The `<details>` elements track their open/closed state, and the inputs hold user values. Now imagine a parent component that toggles the panel visibility:
-
-```tsx
-'use client'
-
-import { useReducer } from 'react'
-
-function ProductPage() {
-  const [showFilters, toggle] = useReducer((x: boolean) => !x, true)
-
-  return (
-    <>
-      <button onClick={toggle}>Toggle Filters</button>
-      {showFilters && <FiltersPanel />}
-    </>
-  )
-}
-```
-
-When `showFilters` becomes `false`, the panel unmounts and all DOM state is lost - which `<details>` were expanded, what the user typed, slider positions. With Activity, the DOM persists:
-
-```tsx
-'use client'
-
-import { Activity, useReducer } from 'react'
-
-function ProductPage() {
-  const [showFilters, toggle] = useReducer((x: boolean) => !x, true)
-
-  return (
-    <>
-      <button onClick={toggle}>Toggle Filters</button>
-      <Activity mode={showFilters ? 'visible' : 'hidden'}>
-        <FiltersPanel />
-      </Activity>
-    </>
-  )
-}
-```
-
-Now toggling the filters preserves everything - expanded sections, input values, scroll position.
-
-### React-managed state
-
-Without Activity, preserving state across hide/show cycles requires:
-
-- **Lifting state up**: Moving state to a parent component that doesn't unmount
-- **External stores**: Using context, Zustand, Redux, etc.
-
-For DOM-managed state, you'd also need to explicitly control and sync it:
-
-- Convert `<details>` to controlled with the `open` prop
-- Track scroll position and restore it on remount
-- Use controlled inputs for all form fields
-
-This quickly adds up to significant boilerplate. Activity lets you keep state local to the component - both React state and DOM state - without any of this synchronization work.
-
-## Patterns with Activity
-
-### Avoiding state lift
-
-Without Activity, to persist state across hide/show cycles, you must lift it to a parent that won't unmount:
-
-```tsx
-'use client'
-
-import { useState } from 'react'
-
-// Before: State lifted to parent
-function Parent() {
-  const [showEditor, setShowEditor] = useState(true)
-  // State hoisted here to survive Editor unmounting
-  const [draft, setDraft] = useState('')
-  const [cursorPosition, setCursorPosition] = useState(0)
-
-  return (
-    <>
-      <button onClick={() => setShowEditor((s) => !s)}>Toggle Editor</button>
-      {showEditor && (
-        <Editor
-          draft={draft}
-          onDraftChange={setDraft}
-          cursorPosition={cursorPosition}
-          onCursorChange={setCursorPosition}
-        />
-      )}
-    </>
-  )
-}
-```
-
-The `Editor` can't own its own state - it would be destroyed on hide. With Activity, state stays local:
-
-```tsx
-'use client'
-
-import { Activity, useState } from 'react'
-
-// After: State stays in Editor
-function Parent() {
-  const [showEditor, setShowEditor] = useState(true)
-
-  return (
-    <>
-      <button onClick={() => setShowEditor((s) => !s)}>Toggle Editor</button>
-      <Activity mode={showEditor ? 'visible' : 'hidden'}>
-        <Editor />
-      </Activity>
-    </>
-  )
-}
-
-function Editor() {
-  // State lives here - survives hide/show cycles
-  const [draft, setDraft] = useState('')
-  const [cursorPosition, setCursorPosition] = useState(0)
-
-  return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
-}
-```
-
-The parent no longer needs to know about the editor's internal state. This keeps components encapsulated and reduces prop drilling.
-
-### Tabs and navigation
-
-A common pattern is offering multiple views of the same data - for example, a list view and a table view. Each view may have its own scroll position, sort order, or selected items.
-
-```tsx
-'use client'
-
-import { Activity, useState } from 'react'
-
-type DataItem = { id: number; name: string }
-
-function DataViewer({ data }: { data: DataItem[] }) {
-  const [view, setView] = useState<'list' | 'table'>('list')
-
-  return (
-    <>
-      <div>
-        <button onClick={() => setView('list')}>List View</button>
-        <button onClick={() => setView('table')}>Table View</button>
-      </div>
-
-      <Activity mode={view === 'list' ? 'visible' : 'hidden'}>
-        <ListView data={data} />
-      </Activity>
-      <Activity mode={view === 'table' ? 'visible' : 'hidden'}>
-        <TableView data={data} />
-      </Activity>
-    </>
-  )
-}
-```
-
-Both views render and stay up to date with `data`. When you switch views:
-
-- Scroll position in each view is preserved
-- Sort order or column widths in `TableView` persist
-- Selected items in `ListView` remain selected
-
-Without Activity, switching views would unmount one and mount the other, losing all that state. For expensive component trees, this also means re-running all that rendering work on every switch - rebuilding memoized values, re-triggering data fetches, etc. With Activity, the component stays mounted and only re-renders when props or context actually change, not on every toggle.
-
-This pattern extends naturally to route-level navigation. With Cache Components, Next.js uses Activity to preserve route states - when you navigate away and back, the previous route's ephemeral state (scroll position, form inputs, expanded sections) can be restored rather than reset.
-
-### Prerendering hidden content
-
-Activity can prerender content the user hasn't seen yet. Hidden boundaries render at lower priority. Combined with Suspense, this lets you prefetch data for content the user is likely to view next.
-
-```tsx filename="app/page.tsx"
-import { Suspense } from 'react'
-import { ExpandableComments } from './expandable-comments'
-
-async function getCommentsData() {
-  return db.comments.findMany()
-}
-
-export default function Page() {
-  const commentsPromise = getCommentsData()
-
-  return (
-    <article>
-      <h1>Post Title</h1>
-      <p>Main content visible immediately...</p>
-
-      <ExpandableComments commentsPromise={commentsPromise} />
-    </article>
-  )
-}
-```
-
-```tsx filename="app/expandable-comments.tsx"
-'use client'
-
-import { Activity, Suspense, useState, use } from 'react'
-
-type Comment = { id: string; text: string; author: string }
-
-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>
-    </>
-  )
-}
-
-function Comments({
-  commentsPromise,
-}: {
-  commentsPromise: Promise<Comment[]>
-}) {
-  const comments = use(commentsPromise)
-  return (
-    <ul>
-      {comments.map((c) => (
-        <li key={c.id}>{c.text}</li>
-      ))}
-    </ul>
-  )
-}
-
-function CommentsSkeleton() {
-  return <div>Loading comments...</div>
-}
-```
-
-The Server Component starts fetching comments immediately and passes the promise down. While hidden, the data streams at lower priority. When the user clicks "Show Comments", the `Comments` component resolves the promise with `use()` and the content appears instantly.
-
-### Resetting state
-
-Since Activity preserves state, you need to explicitly reset it when appropriate. Previously, conditional rendering (`{show && <Form />}`) or unmounting would reset state automatically. With Activity, you choose when to reset.
-
-**Option 1: Reset immediately**
-
-The simplest approach - reset state directly in an event handler:
-
-```tsx
-'use client'
-
-import { useState } from 'react'
-
-async function sendMessage(formData: FormData) {
-  // Your send logic
-}
-
-function ContactForm() {
-  const [message, setMessage] = useState('')
-
-  async function handleSubmit(formData: FormData) {
-    await sendMessage(formData)
-    setMessage('') // Reset immediately after submission
-  }
-
-  return (
-    <form action={handleSubmit}>
-      <textarea
-        name="message"
-        value={message}
-        onChange={(e) => setMessage(e.target.value)}
-      />
-      <button type="submit">Send</button>
-    </form>
-  )
-}
-```
-
-**Option 2: Reset when hidden**
-
-Use Effect cleanup to reset state when Activity hides the component. This lets you show a success message until the component is hidden:
-
-```tsx
-'use client'
-
-import { useState, useRef, useLayoutEffect, useTransition } from 'react'
-
-async function signUp(formData: FormData) {
-  // Your signup logic
-}
-
-function NewsletterForm() {
-  const [didSubmit, setDidSubmit] = useState(false)
-  const [isPending, startTransition] = useTransition()
-  const shouldResetRef = useRef(false)
-
-  useLayoutEffect(() => {
-    return () => {
-      if (shouldResetRef.current) {
-        setDidSubmit(false)
-        shouldResetRef.current = false
-      }
-    }
-  }, [])
-
-  return (
-    <div>
-      {!didSubmit ? (
-        <>
-          <p>Sign up for my newsletter:</p>
-          <form
-            action={(formData) => {
-              startTransition(async () => {
-                shouldResetRef.current = true
-                setDidSubmit(true)
-                await signUp(formData)
-              })
-            }}
-          >
-            <input type="email" name="email" placeholder="Email" />
-            <button type="submit" disabled={isPending}>
-              {isPending ? 'Signing up...' : 'Sign up'}
-            </button>
-          </form>
-        </>
-      ) : (
-        <p>Thanks for signing up!</p>
-      )}
-    </div>
-  )
-}
-```
-
-On submission, set `shouldResetRef.current = true` and show the success message. When Activity hides the component, the Effect cleanup runs and resets `didSubmit`. Next time the component becomes visible, the form is ready for a new submission.
-
-> **Good to know:** If your components add global styles (like `overflow-hidden` on `<body>` for modals), ensure your Effects have proper cleanup functions. With Activity, these cleanups run when hidden - just as they would on unmount.
-
-**Option 3: Reset with a key**
-
-You can't always control whether an Activity boundary exists above your component - a parent, layout, or framework might wrap your content in Activity. If your component relied on unmounting to reset state, you can force a reset by changing its `key`. React treats a new key as an entirely new element, discarding the old subtree and mounting a fresh instance:
-
-```tsx
-'use client'
-
-import { useState } from 'react'
-
-function ResettableForm() {
-  const [formKey, setFormKey] = useState(0)
-
-  return (
-    <>
-      <button onClick={() => setFormKey((k) => k + 1)}>Reset Form</button>
-      <ContactForm key={formKey} />
-    </>
-  )
-}
-```
-
-When `formKey` changes, React discards the entire `ContactForm` subtree - all React state, DOM state, and child components are reset.
-
-Use this sparingly while adopting proper cleanup patterns (Options 1 and 2). Key-based resets defeat Activity's preservation benefits and should be a temporary measure, not a general-purpose solution. See the React documentation on [resetting state with a key](https://react.dev/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key) and [resetting a form with a key](https://react.dev/learn/preserving-and-resetting-state#resetting-a-form-with-a-key) for more details.
-
-## Caveats
-
-### Effects and ref cleanups are run
-
-Effects **are cleaned up** when an Activity becomes `hidden`. Callback refs that return cleanup functions also have their cleanups invoked. This is intentional - subscriptions and timers shouldn't run for hidden content.
-
-> **Good to know**: "Effects" refers to React's [`useEffect`](https://react.dev/reference/react/useEffect) and [`useLayoutEffect`](https://react.dev/reference/react/useLayoutEffect) hooks. When Activity hides content, React runs the cleanup functions you return from these hooks, just as if the component were unmounting.
-
-```tsx
-'use client'
-
-import { useEffect } from 'react'
-
-function Timer() {
-  // Cleanup runs when Activity becomes hidden
-  useEffect(() => {
-    const id = setInterval(() => console.log('tick'), 1000)
-    return () => clearInterval(id) // Called on hide
-  }, [])
-
-  return (
-    <div
-      ref={(node) => {
-        // Ref setup: runs when visible
-        return () => {
-          // Ref cleanup: runs on hide
-        }
-      }}
-    />
-  )
-}
-```
-
-React state and `useRef` values persist across hide/show cycles - only the cleanup functions run. You can use these cleanups to reset state or clear DOM state (like input values or scroll position) when hiding.
-
-For example, a form can use a callback ref to reset its fields when Activity hides it:
-
-```tsx
-<form
-  ref={(form) => {
-    // Cleanup function - runs when Activity hides this component
-    return () => form?.reset()
-  }}
->
-  {/* fields */}
-</form>
-```
-
-This means tracking "mount count" via Effects is misleading with Activity - Effects run on every hide-to-visible transition, not just the initial mount. If you need to distinguish the first mount from subsequent visibility changes, use a ref to track it:
-
-```tsx
-'use client'
-
-import { useEffect, useRef } from 'react'
-
-function TrackedComponent() {
-  const hasMountedRef = useRef(false)
-
-  useEffect(() => {
-    if (!hasMountedRef.current) {
-      hasMountedRef.current = true
-      console.log('First mount')
-    } else {
-      console.log('Became visible again')
-    }
-  }, [])
-
-  return <div>...</div>
-}
-```
-
-The ref persists across hide/show cycles (refs aren't cleaned up), so `hasMountedRef.current` stays `true` after the first mount. Each time Activity becomes visible, the Effect runs again - but now it takes the `else` branch, letting you handle visibility changes differently from the initial mount.
-
-### Media elements need explicit cleanup
-
-Elements like `<video>`, `<audio>`, and `<iframe>` have side effects that persist even with `display: none`. Add explicit cleanup:
-
-```tsx
-'use client'
-
-import { useLayoutEffect, useRef } from 'react'
-
-function VideoPlayer({ src }: { src: string }) {
-  const videoRef = useRef<HTMLVideoElement>(null)
-
-  useLayoutEffect(() => {
-    const video = videoRef.current
-    return () => {
-      video?.pause()
-    }
-  }, [])
-
-  return <video ref={videoRef} src={src} controls />
-}
-```
-
-### Text-only children render nothing
-
-If an Activity's children return only text (no DOM elements), nothing renders since there is no element to apply `display: none` to.
-
-## Testing with Activity
-
-Hidden Activity content has `display: none` but remains in the document. This affects end-to-end testing with tools like Playwright, Cypress, or Puppeteer:
-
-- **DOM queries can find hidden elements** - Selectors may match elements regardless of visibility
-- **Interactions with hidden elements fail or timeout** - Most tools wait for elements to become visible before interacting
-- **Assertions may match hidden content** - Be explicit about visibility when asserting element presence
-
-For example, with a tabs component using Activity, both the visible and hidden tab panels exist in the DOM. A query like `page.getByText('Product Name')` could match elements in both panels.
-
-### Use visibility-aware selectors
-
-In Playwright, `getByRole` queries automatically filter by visibility:
-
-```ts
-// Good - getByRole filters by visibility automatically
-await page.getByRole('button', { name: 'Submit' }).click()
-await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
-
-// Also good - getByLabel, getByPlaceholder filter by visibility
-await page.getByLabel('Email').fill('test@example.com')
-await page.getByPlaceholder('Search...').fill('query')
-```
-
-When `getByRole` isn't suitable, use `.locator()` with visibility filtering:
-
-```ts
-// Fallback - filter by visibility explicitly
-await page.locator('.product-card').filter({ visible: true }).first().click()
-await page
-  .locator('[data-testid="timer"]')
-  .filter({ visible: true })
-  .textContent()
-
-// Avoid - may match hidden elements in Activity boundaries
-await page.locator('.product-card').first().click()
-```
-
-`getByRole` is robust to Activity, tabbed navigation, accordions, and any other pattern that keeps hidden content in the DOM. It queries the accessibility tree, which excludes hidden elements. For other testing tools, check their documentation for visibility-aware selectors - Cypress uses `.should('be.visible')` or `{ visible: true }` options.
-
-## Activity with Cache Components
-
-When using Activity with [Cache Components](/docs/app/getting-started/cache-components) navigation, some patterns that relied on component unmounting may behave differently. See [Activity with Cache Components](/docs/app/guides/activity-cache-components) for handling common patterns like dropdowns, dialogs, and testing.

filename: docs/01-app/02-guides/preserving-ui-state.mdx
patch: @@ -0,0 +1,503 @@
+---
+title: Preserving UI state across navigations
+nav_title: Preserving UI state
+description: Learn how to control which UI state is preserved and which resets when navigating between pages.
+related:
+  title: Related
+  description: Learn more about Cache Components and preserving UI state.
+  links:
+    - app/getting-started/cache-components
+---
+
+> **Good to know:** This guide assumes [Cache Components](/docs/app/getting-started/cache-components) is enabled. Enable it by setting [`cacheComponents: true`](/docs/app/api-reference/config/next-config-js/cacheComponents) in your Next config file.
+
+Before Cache Components, preserving page-level state across navigations required workarounds like hoisting state to a [shared layout](/docs/app/getting-started/layouts-and-pages#nesting-layouts) or using an external store. With Cache Components, Next.js preserves state and DOM out of the box.
+
+Instead of unmounting pages on navigation, Next.js hides them using React's [`<Activity>`](https://react.dev/reference/react/Activity) component. The DOM nodes stay in the document (hidden with `display: none`), so both React state and DOM state are preserved: form drafts, scroll positions, expanded `<details>` elements, video playback progress, and more.
+
+Next.js preserves up to 3 routes. Beyond that, the oldest route is evicted and will re-render fresh.
+
+> **Good to know:** Opt-out strategies are being considered for gradual migration.
+
+## Choosing what to preserve
+
+Activity preserves all component state and DOM state by default. For each piece of state, you decide whether that's the right behavior for your UI. The patterns below show common scenarios and how to handle both sides.
+
+### Expandable UI (dropdowns, accordions, panels)
+
+When a user navigates away and returns, Activity preserves the open/closed state of expandable elements.
+
+**When to keep it:** A sidebar with expanded sections, a FAQ accordion, or a filters panel. The user set up their view intentionally, and restoring it avoids re-doing that work.
+
+**When to reset it:** A dropdown menu or popover triggered by a button click. These are transient interactions, not persistent view state. Returning to a page with a dropdown already open is not user friendly.
+
+To reset transient open/closed state, close it in a `useLayoutEffect` cleanup function:
+
+```tsx highlight={8-13}
+'use client'
+
+import { useState, useLayoutEffect } from 'react'
+
+function SettingsDropdown() {
+  const [isOpen, setIsOpen] = useState(false)
+
+  // Close dropdown when this component becomes hidden
+  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>
+  )
+}
+```
+
+When Activity hides this component, the cleanup function runs and resets `isOpen`. When the page becomes visible again, the dropdown is closed. Using `useLayoutEffect` ensures the cleanup runs synchronously before the component is hidden, avoiding any flash of stale state.
+
+You can also use `Link`'s [`onNavigate`](/docs/app/api-reference/components/link#onnavigate) callback to close dropdowns immediately when a navigation link is clicked.
+
+### Dialog and initialization logic
+
+Activity preserves dialog open/closed state. This also affects Effects that run based on that state.
+
+**When to keep it:** A multi-step wizard or a settings panel that the user was actively working in. Preserving the step and input state avoids losing progress.
+
+**When to reset it:** A dialog that runs initialization logic (like focusing an input) each time it opens. If the user navigated away while the dialog was open, Activity preserves `isDialogOpen: true`. Opening it again sets it to `true` when it's already `true`, so no state change happens and the Effect doesn't re-run.
+
+Consider this example:
+
+```tsx
+'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 the user navigated away while the dialog was open, returning and opening the dialog won't trigger the focus Effect because `isDialogOpen` was already `true`.
+
+To fix this, derive the dialog state from something outside the preserved component state like a search param:
+
+```tsx highlight={3,7-9,20,25}
+'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>
+  )
+}
+```
+
+With this approach, `isDialogOpen` derives from the URL rather than component state. When navigating away and returning, the search param is cleared (the URL changed), so `isDialogOpen` becomes `false`. Opening the dialog sets the param, which changes `isDialogOpen` and triggers the Effect.
+
+### Form input values
+
+Activity preserves form input values: text typed into fields, selected options, checkbox states.
+
+**When to keep it:** A search page with filters, a draft the user was composing, or a settings form with unsaved changes. Preserving input state is one of the biggest UX wins because the user doesn't lose work.
+
+**When to reset it:** A "create new item" page where returning should start fresh, or a contact form after successful submission.
+
+To reset form fields when Activity hides the component, use a callback ref:
+
+```tsx
+<form
+  ref={(form) => {
+    // Cleanup function - runs when Activity hides this component
+    return () => form?.reset()
+  }}
+>
+  {/* fields */}
+</form>
+```
+
+This resets the form whenever the user navigates away.
+
+### Action state (`useActionState`)
+
+Activity preserves [`useActionState`](https://react.dev/reference/react/useActionState) results: success messages, error messages, and any other state returned by the action.
+
+**When to keep it:** A ticket redemption form showing "Ticket redeemed successfully", or a settings form showing "Changes saved". Seeing the result of a previous action when returning to the page is useful confirmation so the user can see what happened.
+
+**When to reset it:** A "new transaction" flow where each visit should start fresh, or a form where stale success/error messages would be confusing in a new context.
+
+You can think of `useActionState` as a `useReducer` that allows side effects. It doesn't have to only handle form submissions; you can dispatch any action to it. Adding a `RESET` action gives you a clean way to clear state when Activity hides the component (see [Reset state](https://react.dev/reference/react/useActionState#reset-state) in the React docs):
+
+```tsx highlight={5-6,9-21,26-35}
+'use client'
+
+import { useActionState, useLayoutEffect, useRef, startTransition } from 'react'
+
+type Action = { type: 'SUBMIT'; data: FormData } | { type: 'RESET' }
+type State = { success: boolean; error: string | null }
+
+function CommentForm() {
+  const [state, dispatch, isPending] = useActionState(
+    async (prev: State, action: Action) => {
+      if (action.type === 'RESET') {
+        return { success: false, error: null }
+      }
+      // Handle the form submission
+      const res = await saveComment(action.data)
+      if (!res.ok) return { success: false, error: res.message }
+      shouldReset.current = true
+      return { success: true, error: null }
+    },
+    { success: false, error: null }
+  )
+
+  const shouldReset = useRef(false)
+
+  // Dispatch RESET when Activity hides this component
+  useLayoutEffect(() => {
+    return () => {
+      if (shouldReset.current) {
+        shouldReset.current = false
+        startTransition(() => {
+          dispatch({ type: 'RESET' })
+        })
+      }
+    }
+  }, [dispatch])
+
+  return (
+    <form action={(formData) => dispatch({ type: 'SUBMIT', data: formData })}>
+      <textarea name="comment" />
+      <button type="submit" disabled={isPending}>
+        {isPending ? 'Posting...' : 'Post Comment'}
+      </button>
+      {state.success && <p>Comment posted!</p>}
+      {state.error && <p>{state.error}</p>}
+    </form>
+  )
+}
+```
+
+Here's what happens step by step:
+
+1. The user submits the form. The reducer receives a `SUBMIT` action with the `FormData`, calls `saveComment`, and returns `{ success: true }`. It also sets `shouldReset.current = true` to mark that a reset is needed.
+2. The user navigates away. Activity hides the component and runs the `useLayoutEffect` cleanup. Because `shouldReset.current` is `true`, it dispatches a `RESET` action.
+3. The reducer receives `RESET` and returns the initial state (`{ success: false, error: null }`). The stale success message is cleared.
+4. If the user navigates back, the form is ready for a new submission. If they never submitted (step 1 didn't happen), `shouldReset.current` is still `false`, so no `RESET` is dispatched. The form stays as-is.
+
+## State and authentication
+
+Activity preserves local component state (`useState`, DOM input values) across navigations, including authentication changes. This is standard React behavior: props changing (such as receiving a new user) triggers a re-render but does not reset existing state. A draft composed by one user shouldn't be visible to another.
+
+For logout flows, using `window.location.href` instead of `router.push` triggers a full page reload, clearing all client-side state.
+
+To reset specific state when the user changes without a full reload:
+
+```tsx
+'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('') // Reset on user change
+    }
+    lastUserIdRef.current = userId
+  }, [userId])
+
+  return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
+}
+```
+
+Alternatively, key components by user ID to let React handle the reset: `<Form key={userId} />`.
+
+## Global styles
+
+Page-level styles (CSS variables, z-index, global classes) can affect visible pages when the originating component is hidden by Activity. You likely want to disable them when hidden: a hidden page's accent color or z-index overrides shouldn't leak into the visible page.
+
+Use a callback ref to toggle the stylesheet's `media` attribute:
+
+```tsx
+<style
+  ref={(style) => {
+    if (style) style.media = '' // Enable when visible
+    return () => {
+      if (style) style.media = 'not all' // Disable when hidden
+    }
+  }}
+>
+  {`:root { --page-accent: blue; }`}
+</style>
+```
+
+Or use `useLayoutEffect` when managing multiple style elements or more complex cleanup:
+
+```tsx
+'use client'
+
+import { useLayoutEffect, useRef } from 'react'
+
+function PageWithStyles() {
+  const styleRef = useRef<HTMLStyleElement>(null)
+
+  useLayoutEffect(() => {
+    if (styleRef.current) styleRef.current.media = ''
+    return () => {
+      if (styleRef.current) styleRef.current.media = 'not all'
+    }
+  }, [])
+
+  return <style ref={styleRef}>{`:root { --page-accent: blue; }`}</style>
+}
+```
+
+When Activity hides the component, the cleanup sets `media="not all"`, which disables the stylesheet. When visible again, the effect re-runs and resets `media` to enable it.
+
+## Testing
+
+Hidden Activity content has `display: none` but remains in the document. This applies both to routes preserved by Cache Components and to content you hide with `<Activity>` directly. It affects end-to-end testing with tools like Playwright, Cypress, or Puppeteer:
+
+- **DOM queries can find hidden elements.** Selectors may match elements regardless of visibility.
+- **Interactions with hidden elements fail or timeout.** Most tools wait for elements to become visible before interacting.
+- **Assertions may match hidden content.** Be explicit about visibility when asserting element presence.
+
+### Use visibility-aware selectors
+
+In Playwright, `getByRole` queries automatically filter by visibility:
+
+```ts
+// Good - getByRole filters by visibility automatically
+await page.getByRole('button', { name: 'Submit' }).click()
+await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
+
+// Also good - getByLabel, getByPlaceholder filter by visibility
+await page.getByLabel('Email').fill('test@example.com')
+await page.getByPlaceholder('Search...').fill('query')
+```
+
+When `getByRole` isn't suitable, use `.locator()` with visibility filtering:
+
+```ts
+// Fallback - filter by visibility explicitly
+await page.locator('.product-card').filter({ visible: true }).first().click()
+await page
+  .locator('[data-testid="timer"]')
+  .filter({ visible: true })
+  .textContent()
+
+// Avoid - may match hidden elements in Activity boundaries
+await page.locator('.product-card').first().click()
+```
+
+`getByRole` is robust to Activity, tabbed navigation, accordions, and any other pattern that keeps hidden content in the DOM. It queries the accessibility tree, which excludes hidden elements. For other testing tools, check their documentation for visibility-aware selectors. For example, Cypress uses `.should('be.visible')` or `{ visible: true }` options.
+
+## Using Activity in your components
+
+Cache Components uses Activity automatically at the route level, but you can also use `<Activity>` directly in your own components. This is useful for tabs, expandable panels, or any UI where you want to hide content without unmounting it.
+
+### Prerendering hidden content
+
+Activity can prerender content the user hasn't seen yet. Hidden boundaries render at lower priority. Combined with Suspense, this lets you prefetch data for content the user is likely to view next.
+
+A Server Component can start fetching data immediately and pass the promise to a client component. The client component uses Activity to hide the content until the user requests it, and `use()` to resolve the promise when rendering:
+
+```tsx filename="app/page.tsx"
+import { Suspense } from 'react'
+import { ExpandableComments } from './expandable-comments'
+
+async function getCommentsData() {
+  return db.comments.findMany()
+}
+
+export default function Page() {
+  const commentsPromise = getCommentsData()
+
+  return (
+    <article>
+      <h1>Post Title</h1>
+      <p>Main content visible immediately...</p>
+
+      <ExpandableComments commentsPromise={commentsPromise} />
+    </article>
+  )
+}
+```
+
+```tsx filename="app/expandable-comments.tsx"
+'use client'
+
+import { Activity, Suspense, useState, use } from 'react'
+
+type Comment = { id: string; text: string; author: string }
+
+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>
+    </>
+  )
+}
+
+function Comments({
+  commentsPromise,
+}: {
+  commentsPromise: Promise<Comment[]>
+}) {
+  const comments = use(commentsPromise)
+  return (
+    <ul>
+      {comments.map((c) => (
+        <li key={c.id}>{c.text}</li>
+      ))}
+    </ul>
+  )
+}
+
+function CommentsSkeleton() {
+  return <div>Loading comments...</div>
+}
+```
+
+The Server Component starts fetching comments immediately and passes the promise down. While hidden, the data streams at lower priority. When the user clicks "Show Comments", the `Comments` component resolves the promise with `use()` and the content appears instantly.
+
+### Effect and media cleanup
+
+When Activity hides content, React runs effect cleanup functions just like it does on unmount. This means timers, subscriptions, and media playback pause automatically if you have proper cleanup:
+
+```tsx
+'use client'
+
+import { useEffect, useState } from 'react'
+
+function LiveTimer() {
+  const [count, setCount] = useState(0)
+
+  useEffect(() => {
+    const id = setInterval(() => setCount((c) => c + 1), 1000)
+    return () => clearInterval(id) // Pauses when hidden
+  }, [])
+
+  return <p>Count: {count}</p>
+}
+```
+
+For media elements like `<video>` and `<audio>`, `display: none` does not stop playback. Add explicit cleanup with `useLayoutEffect`:
+
+```tsx
+'use client'
+
+import { useLayoutEffect, useRef } from 'react'
+
+function VideoPlayer({ src }: { src: string }) {
+  const videoRef = useRef<HTMLVideoElement>(null)
+
+  useLayoutEffect(() => {
+    const video = videoRef.current
+    return () => {
+      video?.pause() // Pauses when hidden, preserves playback position
+    }
+  }, [])
+
+  return <video ref={videoRef} src={src} controls />
+}
+```
+
+When the component becomes visible again, effects re-run and playback position is preserved since the DOM node was never removed.
+
+### Distinguishing first mount from re-show
+
+Effects run on every hide-to-visible transition, not just the initial mount. If you need to distinguish the first mount from subsequent visibility changes, use a ref:
+
+```tsx
+'use client'
+
+import { useEffect, useRef } from 'react'
+
+function TrackedComponent() {
+  const hasMountedRef = useRef(false)
+
+  useEffect(() => {
+    if (!hasMountedRef.current) {
+      hasMountedRef.current = true
+      console.log('First mount')
+    } else {
+      console.log('Became visible again')
+    }
+  }, [])
+
+  return <div>...</div>
+}
+```
+
+The ref persists across hide/show cycles (refs aren't cleaned up), so `hasMountedRef.current` stays `true` after the first mount. Each time Activity becomes visible, the Effect runs again, but now it takes the `else` branch.
+
+## Examples
+
+The [Activity Patterns Demo](https://react-activity-patterns.vercel.app/) ([source](https://github.com/vercel-labs/react-activity-patterns)) is a Next.js app with Cache Components enabled and three routes. Navigate between them to see state preservation in action:
+
+- **Data** — sortable table and selectable list that keep their state across navigations, plus a reviews section that prerenders in the background
+- **Forms** — filter panel with DOM state (`<details>`, checkboxes, text inputs) that persists, and a newsletter form that resets after submission using `useLayoutEffect` cleanup
+- **Side Effects** — a live timer that pauses when you navigate away and resumes when you return, and a video player that auto-pauses with playback position preserved