--- 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/caching --- > **Good to know:** This guide assumes [Cache Components](/docs/app/getting-started/caching) 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 [``](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 `
` 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 (
{isOpen && (
)}
) } ``` 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(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(null) useEffect(() => { if (isDialogOpen) { inputRef.current?.focus() } }, [isDialogOpen]) return (
{isDialogOpen && ( )}
) } ``` 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
{ // Cleanup function - runs when Activity hides this component return () => form?.reset() }} > {/* fields */}
``` 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 (
dispatch({ type: 'SUBMIT', data: formData })}>