Capítulo 48 de 456

Interactive apps

Core Idea

Guia passo a passo (app "Taskboard") para dar feedback responsivo a interações que dependem de trabalho server-side: streaming de leituras lentas, toggles instantâneos, filtros com pending state, comentários otimistas, drag-and-drop, formulários com useActionState, e cache com invalidação precisa para tornar navegação repetida instantânea.

Key Concepts

  • <Suspense> com promise inline: envolver params.then(...) diretamente no JSX mantém a página síncrona e permite streaming por seção sem bloquear no fetch mais lento.
  • useOptimistic(value): fornece um valor otimista para renderizar no lugar da prop stale enquanto uma transição está pendente; reverte ao valor real quando a transição termina.
  • useTransition() / startTransition(): roda a Server Function como parte de uma transição; erros lançados dentro são encaminhados ao error boundary mais próximo sem try/catch manual.
  • Convenção xAction/changeAction: prop nomeada com sufixo Action sinaliza que o componente consumidor deve rodá-la dentro de sua própria transição (padrão do React para "Exposing action props from components").
  • data-pending attribute + :has(): componente filho expõe estado pendente via atributo de dado; ancestral estiliza com group-has-data-pending: ou has-data-pending: sem precisar de callback/lifting state. Evitar em interações de alta frequência (drag, scroll) porque o browser reavalia :has() na subtree a cada toggle.
  • useOptimistic([]) para listas pendentes: separa a lista persistida (server component) da lista de itens ainda não confirmados (client component), reconciliadas quando o server render fresco chega.
  • formRef.current?.reset(): dentro de uma transição, useState setters são adiados até o fim da transição, mas manipulação direta do DOM (como reset de formulário) e setters de useOptimistic aplicam no frame atual.
  • useOptimistic com reducer: recebe a lista completa e uma action, remapeando um item específico; se um refresh em background chegar no meio (polling, mutação de outro usuário), React reaplica o reducer sobre os dados atualizados.
  • useActionState(action, initialState): gerencia isPending do botão, reset de campos via key incremental, e roda a action já dentro de uma transição.
  • Limitação: state update pós-await não é transição automaticamente: dentro de uma action de useActionState, atualizações de estado depois do await (ex.: setIsOpen(false)) precisam ser envolvidas manualmente em startTransition para serem batchadas com o resto.
  • refresh(): reroda trabalho dinâmico mas deixa entradas cacheadas intactas; usar para mutações em dados que permanecem dinâmicos (ex.: thread de comentários).
  • updateTag(tag): invalida entradas cacheadas por tag após uma escrita, substituindo refresh() quando a leitura já está sob 'use cache' + cacheTag.

Code Examples

// features/task/components/task-card.tsx — toggle otimista
'use client'
import { useOptimistic, useTransition } from 'react'
import { cyclePriority } from '@/features/task/task-actions'
import { PRIORITY_CYCLE } from '@/lib/data'

export function TaskCard({ id, priority }) {
  const [optimisticPriority, setOptimisticPriority] = useOptimistic(priority)
  const [, startTransition] = useTransition()

  function handlePriority() {
    startTransition(async () => {
      setOptimisticPriority(PRIORITY_CYCLE[optimisticPriority])
      await cyclePriority(id)
    })
  }

  return (
    <button onClick={handlePriority} className={priorityDot[optimisticPriority]}>
      {optimisticPriority}
    </button>
  )
}
  • O que demonstra: useOptimistic + useTransition para atualizar UI no frame atual sem esperar a Server Function.
// features/task/components/board.tsx — drag-and-drop com reducer otimista
const [optimisticTasks, moveTask] = useOptimistic(
  tasks,
  (currentTasks, action: { taskId: string; status: Status }) =>
    currentTasks.map((t) => (t.id === action.taskId ? { ...t, status: action.status } : t))
)

function handleDrop(targetStatus, taskId) {
  startTransition(async () => {
    moveTask({ taskId, status: targetStatus })
    const result = await updateStatus(taskId, targetStatus)
    if (!result.success) toast.error(result.error)
  })
}
  • O que demonstra: useOptimistic com reducer para remapear um item numa lista, usando startTransition puro (não useTransition) para não disparar isPending indesejado.
// features/task/task-queries.ts — leitura cacheada e invalidação
import { cacheLife, cacheTag } from 'next/cache'
import { getTaskById } from '@/lib/db'

export async function getTask(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('tasks', `task-${id}`)
  return getTaskById(id)
}
// features/task/task-actions.ts
'use server'
import { updateTag } from 'next/cache'

export async function updateStatus(taskId: string, newStatus: Status) {
  const updated = await updateTaskStatus(taskId, newStatus)
  if (!updated) return { success: false as const, error: 'Task no longer exists' }
  updateTag('tasks')
  updateTag(`task-${taskId}`)
  return { success: true as const, status: newStatus }
}
  • O que demonstra: cache por tag (cacheTag) e invalidação precisa (updateTag) substituindo refresh() para deixar navegações repetidas instantâneas.

Reference Tables

SituaçãoUse
Dado lento deve fazer stream sem bloquear a página<Suspense>
Um valor deve atualizar enquanto trabalho assíncrono rodauseOptimistic
Trabalho assíncrono precisa de pending state, erro, ou UI coordenadauseTransition
Formulário precisa de pending, reset e result stateuseActionState
Ancestral deve mostrar pending state de trabalho acontecendo em outro lugaratributo data-pending estilizado via CSS
Leituras reutilizáveis devem sobreviver entre requests e ficar frescas após writes'use cache' + cacheTag, revalidado por updateTag ou revalidateTag
Navegação entre páginas deve parecer instantânea<Link> prefetching (Partial Prefetching) + prefetch={true} para conteúdo dependente de URL

Anti-patterns

  • Chamar Server Function direto no onClick sem useOptimistic/useTransition: a UI continua renderizando a prop antiga até o próximo server render chegar.
  • Usar useState para pending state dentro de uma action de formulário: setIsOpen(false) fora de transição fecha o diálogo antes do board atualizar, produzindo dessincronia visual de um frame.
  • Usar useTransition().isPending quando o feedback visual já vem do próprio valor otimista: no drag-and-drop, isso dispara o mesmo fade usado por outro pending state (filtro), causando comportamento indesejado; prefira startTransition isolado.
  • :has() (data-pending) em interações de alta frequência: dragging/scrolling recalculando a subtree inteira a cada toggle é caro; use client state nesses casos.

Key Takeaways

  1. useOptimistic some ao fim da transição e é substituído pelo valor real vindo do próximo server render, não é um estado permanente.
  2. Efeitos colaterais que não afetam estado renderizado (toasts, analytics, foco) rodam livremente após o await, sem precisar de startTransition.
  3. O padrão de nomear props xAction comunica ao consumidor que a função deve ser chamada dentro de uma transição.
  4. refresh() é para dados que permanecem dinâmicos; updateTag é para dados cacheados que precisam de invalidação cirúrgica.
  5. Cache Components (cacheComponents: true) é pré-requisito só do Step 8 (navegação instantânea); os Steps 1-7 funcionam sem ele.

Connects To

  • Instant navigation (ch046): Step 8 desta página aplica exatamente os primitivos de cache/prefetch detalhados lá.
  • Streaming: aprofunda os padrões de <Suspense> usados no Step 1.
  • Revalidating: tag patterns e revalidação time-based/on-demand usados na invalidação de cache do Step 8.