Capítulo 69 de 456

Preserving UI state

Core Idea

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.

Key Concepts

  • <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.
  • Deriving state from URL: instead of local useState for dialog/modal open state (which Activity preserves and can suppress re-run of init effects), derive isOpen from useSearchParams().
  • Effect cleanup on hide: React runs effect cleanup functions when Activity hides content, same as unmount — good for timers/subscriptions; <video>/<audio> need explicit .pause() in useLayoutEffect since display:none doesn't stop playback.

Code Examples

'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>
  )
}
  • O que demonstra: fecha um dropdown transiente quando a página é escondida pela Activity, evitando reabrir aberto ao voltar.
'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>
  )
}
  • O que demonstra: derivar 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; }
  • O que demonstra: usar atributo 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>
    </>
  )
}
  • O que demonstra: uso direto de <Activity mode="hidden"> para pré-renderizar conteúdo que o usuário ainda não pediu, em prioridade baixa.

Anti-patterns

  • Confiar em 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.
  • Usar :root:has(...) para estilo global condicionado a componente escondido: acopla componentes não relacionados e é ruim de performance; prefira data-* no <html>.
  • Testar com Playwright usando seletor .locator() genérico: pode casar elementos escondidos pela Activity (display:none); prefira getByRole (filtra por visibilidade) ou .filter({ visible: true }).
  • Esperar que <video>/<audio> pausem sozinhos ao esconder: display:none não pausa mídia; adicione video?.pause() em cleanup de useLayoutEffect.

Key Takeaways

  1. Activity preserva estado por padrão; a decisão de resetar é sempre explícita, por padrão de UI (transiente vs. persistente).
  2. useLayoutEffect cleanup é o mecanismo canônico para resetar estado ao esconder (dropdown, mensagens de sucesso stale, formulários).
  3. Estado de diálogo/init deve derivar de fonte externa ao componente (URL) quando precisa re-disparar efeitos.
  4. Estilos globais (CSS vars, z-index) de uma página escondida podem vazar para a visível; desative via media = 'not all' no cleanup.
  5. Em testes E2E, conteúdo escondido pela Activity continua no DOM — usar seletores conscientes de visibilidade.

Connects To

  • caching (getting-started): Cache Components é pré-requisito para esse comportamento de Activity.
  • preventing-flash: ambos lidam com sincronizar estado client-only sem gerar flash/erro de hidratação.