Árbol de Decisión de Estado
01
Aplica este árbol a cada variable de estado del dashboard de FlowMetrics antes de elegir mecanismo.
PREGUNTA
¿Solo lo usa un componente?
✓
useState local
¿Lo necesitan padre + pocos hijos?
↑ Lift a ancestor común
¿Branches distantes + lectura infrecuente (tema, auth, locale)?
🔗
React.Context
¿Actualizaciones frecuentes compartidas en el árbol?
⚡
Zustand / Jotai
¿Derivado de servidor (deals, pipeline data)?
☁
TanStack Query
Regla FlowMetrics: El estado del filtro de fechas del dashboard es Zustand (alta frecuencia, compartido por 4+ componentes). Los datos de deals son TanStack Query. El modal de creación de pipeline usa useState local — no sale de ese componente.
Server vs Client Components
02
⚙️ SERVER COMPONENTS
DashboardPage — carga deals desde DB
PipelineListPage — renderiza lista estática
UserProfileLayout — perfil desde sesión
ReportPage — genera agregados servidor
async · sin JS cliente · acceso DB directo
🖱 CLIENT COMPONENTS
DealSearchBox — onChange / debounce
DateRangePicker — estado interactivo
AddToStageButton — useTransition
CreatePipelineForm — useActionState
"use client" · hooks · eventos · interactividad
TSX · app/dashboard/page.tsx (Server Component)
// Server Component — default, async, sin "use client" export default async function DashboardPage() { // Acceso directo a DB — no llega al cliente const deals = await db.deal.findMany({ where: { status: "active" }, orderBy: { updatedAt: "desc" }, }); return ( <main> <PipelineHeader /> // Server <DealSearchBox /> // Client — "use client" interno <DealList deals={deals} /> // Server — props serializables </main> ); }
Regla de oro: Nunca importes un Server Component desde un archivo
"use client". Si necesitas composición, pásalo como children.
Formulario con
03
useActionState — React 19
Crear pipeline en FlowMetrics usando el nuevo API de React 19. Sin gestión de estado manual para pending/error.
TSX · components/CreatePipelineForm.tsx
"use client"; import { useActionState } from "react"; import { createPipelineAction } from "@/actions/pipeline"; const initial = { error: null as string | null, success: false }; export function CreatePipelineForm() { const [state, formAction, pending] = useActionState( createPipelineAction, initial ); return ( <form action={formAction} aria-label="Crear pipeline"> <fieldset disabled={pending}> <label htmlFor="name">Nombre del pipeline</label> <input id="name" name="name" required minLength={3} /> <label htmlFor="currency">Moneda</label> <select id="currency" name="currency"> <option value="EUR">EUR</option> <option value="USD">USD</option> </select> <button type="submit" aria-busy={pending}> {pending ? "Creando..." : "Crear pipeline"} </button> </fieldset> {state.error && ( <p role="alert" aria-live="assertive"> {state.error} </p> )} {state.success && ( <p role="status">Pipeline creado correctamente ✓</p> )} </form> ); }
TS · actions/pipeline.ts (Server Action)
"use server"; import { PipelineSchema } from "@/lib/schemas"; import { db } from "@/lib/db"; export async function createPipelineAction( _prev: { error: string | null; success: boolean }, formData: FormData ) { const parsed = PipelineSchema.safeParse( Object.fromEntries(formData) ); if (!parsed.success) return { error: "Datos inválidos: " + parsed.error.flatten().fieldErrors, success: false }; await db.pipeline.create({ data: parsed.data }); revalidatePath("/dashboard"); return { error: null, success: true }; }
Optimistic UI con
04
useOptimistic
Mover deals entre etapas del pipeline sin esperar al servidor — la UI refleja el cambio instantáneamente y revierte si falla.
TSX · components/DealKanbanColumn.tsx
"use client"; import { useOptimistic, useTransition } from "react"; import { moveDeal } from "@/actions/deal"; import type { Deal, Stage } from "@/types"; type Props = { deals: Deal[]; targetStage: Stage }; export function DealKanbanColumn({ deals, targetStage }: Props) { const [isPending, startTransition] = useTransition(); // Estado optimista: reducer mueve el deal a la nueva etapa const [optimisticDeals, moveOptimistic] = useOptimistic( deals, (state: Deal[], { dealId, stage }: { dealId: string; stage: Stage }) => state.map((d) => d.id === dealId ? { ...d, stage } : d) ); function handleMove(dealId: string) { startTransition(async () => { moveOptimistic({ dealId, stage: targetStage }); // Actualización inmediata await moveDeal(dealId, targetStage); // Revalida o revierte }); } return ( <ul aria-label={`Etapa: ${targetStage}`}> {optimisticDeals.map((deal) => ( <li key={deal.id}> <span>{deal.name}</span> <button onClick={() => handleMove(deal.id)} disabled={isPending} aria-label={`Mover ${deal.name} a ${targetStage}`} > Mover → </button> </li> ))} </ul> ); }
useOptimistic solo funciona dentro de una startTransition. El estado optimista se revierte automáticamente si la acción falla — no necesitas manejo de error manual para la UI.
Hook personalizado
05
usePipelineStats
usePipelineStats(query, options)
Busca deals con debounce + cacheo via TanStack Query. Encapsula la lógica de debounce + fetching que se repite en DealSearchBox y ReportFilters.
query string
dateRange DateRange
stageFilter Stage[]
↩ data PipelineStats | undefined
↩ isLoading boolean
↩ debouncedQuery string
TS · hooks/usePipelineStats.ts
import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import type { DateRange, Stage, PipelineStats } from "@/types"; import { fetchPipelineStats } from "@/lib/api"; // Hook reutilizable de debounce — mismo patrón que react-patterns SKILL function useDebounce<T>(value: T, delay = 300): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); // Cleanup obligatorio }, [value, delay]); return debounced; } export function usePipelineStats( query: string, { dateRange, stageFilter }: { dateRange: DateRange; stageFilter: Stage[] } ) { const debouncedQuery = useDebounce(query, 350); const { data, isLoading, error } = useQuery<PipelineStats>({ queryKey: ["pipeline-stats", debouncedQuery, dateRange, stageFilter], queryFn: () => fetchPipelineStats({ query: debouncedQuery, dateRange, stageFilter }), enabled: debouncedQuery.length > 0 || stageFilter.length > 0, staleTime: 1000 * 30, // 30s de cache antes de revalidar }); return { data, isLoading, error, debouncedQuery }; }
Extrae el hook porque la misma secuencia (
useState + useEffect + useQuery) aparece en DealSearchBox y ReportFilters. Regla de la skill: extraer solo cuando el patrón se repite en 2+ componentes.
Suspense + Error Boundary — Módulo de Reportes
06
✗ Evitar — boundary en raíz de ruta
<ErrorBoundary> <Suspense fallback=<PageSpinner />> <ReportsPage /> // toda la página espera </Suspense> </ErrorBoundary>
✓ Correcto — boundaries cercanos a datos
<ReportsLayout> <ErrorBoundary fallback=<ChartError />> <Suspense fallback=<ChartSkeleton />> <RevenueChart /> </Suspense> </ErrorBoundary> <ErrorBoundary fallback=<TableError />> <Suspense fallback=<TableSkeleton />> <DealsTable /> </Suspense> </ErrorBoundary> </ReportsLayout>
TSX · app/reports/layout.tsx
import { ErrorBoundary } from "react-error-boundary"; import { Suspense } from "react"; export default function ReportsLayout({ children }: { children: React.ReactNode }) { return ( <section aria-labelledby="reports-heading"> <h1 id="reports-heading">Reportes de Pipeline</h1> /* Cada bloque de datos tiene su propio boundary */ <ErrorBoundary fallback=<ChartErrorView /> onError={(err) => captureException(err)} > <Suspense fallback=<RevenueChartSkeleton />> <RevenueChart /> </Suspense> </ErrorBoundary> <ErrorBoundary fallback=<TableErrorView />> <Suspense fallback=<DealsTableSkeleton />> {children} </Suspense> </ErrorBoundary> </section> ); }
Límite del Error Boundary: NO captura errores en event handlers ni en código async fuera de render. Usar
try/catch en esos casos y actualizar estado de error manualmente.
Matriz de Data Fetching — FlowMetrics
07
| Caso en FlowMetrics | Herramienta | Recomendación | Por qué |
|---|---|---|---|
| Deals iniciales del dashboard | RSC await fetch() | Recomendado | Servidor → no JS cliente, SEO, 0 waterfall |
| Buscador de deals en tiempo real | TanStack Query | Recomendado | Cache + retry + invalidación + Suspense |
| Estadísticas del pipeline sidebar | SWR | Alternativa | Más ligero si no necesitas mutations |
| Notificaciones push en tiempo real | SSE / WebSocket | Recomendado | Suscripción continua — no polling |
| Actualizar stage de un deal | Server Action + invalidate | Recomendado | Mutación via form action + revalidatePath |
| Petición one-off (export CSV) | fetch() en handler | Válido | Sin cache necesario, fire-and-forget |
| useEffect + fetch sin lib | — | Evitar | Race conditions, sin retry, sin Suspense |
Composición y Accesibilidad
08
Checklist de accesibilidad para los componentes interactivos de FlowMetrics:
-
HTML semántico primero:
<button>para acciones,<a>para navegación,<table>para datos tabulares — nunca<div onClick> -
Labels en todos los inputs:
<label htmlFor>explícito oaria-labelsi el label es visual. El buscador de deals llevaaria-label="Buscar deals" -
Estados ARIA en acciones async:
aria-busy={pending}en botones de submit,aria-live="assertive"en mensajes de error -
Focus management en modales: Al abrir el modal de creación de pipeline, mover el foco al primer campo. Al cerrar, devolverlo al botón trigger.
-
Keyboard-only navigation: El kanban de etapas debe operar con Tab + Enter/Space. Los drag-and-drop necesitan alternativa de teclado.
-
Test con axe: Integrar
@axe-core/reacten Storybook yjest-axeen tests de componentes — pendiente de configurar en FlowMetrics
TSX · Compound Component — PipelineStageSelector
// Patrón compound: estado compartido via Context, API limpia <StageSelector defaultValue="prospecting"> <StageSelector.List aria-label="Seleccionar etapa del pipeline"> <StageSelector.Option value="prospecting">Prospección</StageSelector.Option> <StageSelector.Option value="qualified">Calificado</StageSelector.Option> <StageSelector.Option value="proposal">Propuesta</StageSelector.Option> <StageSelector.Option value="closed">Cerrado</StageSelector.Option> </StageSelector.List> </StageSelector>