🧬 CULTIVA IA  ·  Guía de Patrones

Patrones de React 18/19

Referencia idiomática aplicada a FlowMetrics — SaaS B2B de analytics de pipelines de ventas · Next.js 15 + React 19 + TypeScript

React 19 · useActionState, useOptimistic 🗂 Stack · Next.js App Router · TanStack Query v5 · Zustand 🎯 Nivel · Intermedio–Avanzado 📅 Junio 2026
🔀
Á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 useActionState — React 19
03

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 useOptimistic
04

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 usePipelineStats
05
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 o aria-label si el label es visual. El buscador de deals lleva aria-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/react en Storybook y jest-axe en 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>