Capítulo 69 de 456
With Cache Components enabled, Next.js hides navigated-away pages using React's <Activity> component instead of unmounting them, preserving both React state and DOM state (form drafts, scroll, video playback) across up to 3 routes. This page shows which patterns to keep as-is and which to explicitly reset.
<Activity>: React component Next.js uses under Cache Components to hide pages with display: none instead of unmounting, keeping state and DOM intact. Preserves up to 3 routes; oldest is evicted beyond that.useRouter().bfcacheId: stable id usable as a React key on a <Fragment key={bfcacheId}> to reset a whole subtree on push/replace navigation while still restoring state on back/forward; mainly a migration tool, prefer per-pattern resets for new code.useLayoutEffect cleanup: runs synchronously when Activity hides a component, the standard way to reset transient state (open dropdowns, stale success messages) before hiding.useState for dialog/modal open state (which Activity preserves and can suppress re-run of init effects), derive isOpen from useSearchParams().<video>/<audio> need explicit .pause() in useLayoutEffect since display:none doesn't stop playback.'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></ul>}
</div>
)
}
'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>
)
}
isDialogOpen da URL para garantir que o efeito de foco rode de novo mesmo se a Activity preservou true de uma visita anterior.<html data-modal-open={modalOpen ? "true" : undefined}>
html[data-modal-open='true'] { overflow: hidden; }
data-* controlado pelo React em vez de :root:has(...), que ignora o data flow do React.'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' : 'Show'} Comments</button>
<Activity mode={expanded ? 'visible' : 'hidden'}>
<Suspense fallback={<CommentsSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</Activity>
</>
)
}
<Activity mode="hidden"> para pré-renderizar conteúdo que o usuário ainda não pediu, em prioridade baixa.useState de inicialização para efeito de foco/init em diálogo: Activity preserva isDialogOpen: true entre navegações, então o efeito não re-dispara; derive do URL em vez disso.:root:has(...) para estilo global condicionado a componente escondido: acopla componentes não relacionados e é ruim de performance; prefira data-* no <html>..locator() genérico: pode casar elementos escondidos pela Activity (display:none); prefira getByRole (filtra por visibilidade) ou .filter({ visible: true }).<video>/<audio> pausem sozinhos ao esconder: display:none não pausa mídia; adicione video?.pause() em cleanup de useLayoutEffect.useLayoutEffect cleanup é o mecanismo canônico para resetar estado ao esconder (dropdown, mensagens de sucesso stale, formulários).media = 'not all' no cleanup.