---
name: react-experto
description: React + Next.js mejores prácticas modernas. Cubre Server Components vs Client Components, data fetching patterns (RSC, React Query, SWR, Server Actions), state management (useState, Zustand, Jotai), performance (memo, lazy, Suspense, streaming) y Next.js App Router patterns.
version: "1.2.0"
evolved: true
evolved-from: "1.1.1"
evolved-at: "2026-05-12"
evolved-by: "aprender"
evolved-note: "Backport L-138 SIGM: Sidebar/menu con RBAC debe ocultar secciones cuyos items quedan todos filtrados; previo L-105 setState in useEffect"
herramientasPermitidas: [Read]
exclusiones:
  - "No cargar para optimización de rendimiento React (memo, useMemo, useCallback, virtualización, code splitting) — para rendimiento cargar `react-optimizacion`."
  - "No cargar para patrones y configuraciones específicas de Next.js (App Router avanzado, middleware, ISR, layouts anidados) — para Next.js cargar `nextjs-experto`."
  - "No cargar para Angular, Vue o Svelte — Server Components y el modelo de composición son específicos de React."
  - "No cargar para testing React (Testing Library, Vitest, MSW) — para testing cargar `nextjs-testing`."
evolvable: true  # default para skill estandar
---
# React Experto — React 19 + Next.js 15

## Cuándo NO cargar

- La tarea es específicamente optimización de rendimiento React: memo, useMemo, useCallback, virtualización o code splitting — cargar `react-optimizacion`.
- La pregunta es sobre Next.js avanzado: middleware, ISR, layouts anidados, route handlers — cargar `nextjs-experto`.
- El framework es Angular, Vue o Svelte — Server Components y el modelo de composición RSC son específicos de React/Next.js.
- La tarea es escribir tests React con Testing Library, Vitest o MSW — cargar `nextjs-testing`.

## Principio Fundamental

React moderno separa responsabilidades en dos mundos:
- **Server Components (RSC)**: renderizan en el servidor, acceden a datos directamente, NO tienen estado ni efectos
- **Client Components**: se ejecutan en el navegador, tienen estado, efectos e interactividad

La regla de oro: **por defecto todo es Server Component**. Solo se agrega `"use client"` cuando se necesita interactividad.

---

## Reglas Obligatorias

1. **Server Component por defecto** — solo agregar `"use client"` cuando se necesita interactividad (onClick, useState, useEffect, browser APIs).
2. **NUNCA importar Server Component desde Client Component** — el flujo es Server -> Client (via props/children).
3. **NUNCA fetch en useEffect cuando RSC esta disponible** — usar Server Component con fetch directo.
4. **NUNCA useEffect para transformar datos** — usar `useMemo` para valores derivados.
5. **NUNCA key con index en listas mutables** — usar ID estable.
6. **Validar con Zod en Server Actions** — SIEMPRE validar server-side.
7. **Selectores especificos en stores** — evitar re-renders suscribiendose solo al slice necesario.

---

## Server Components vs Client Components

### Server Component (default)

```tsx
// app/usuarios/page.tsx — NO lleva "use client"
import { db } from '@/lib/db';

export default async function UsuariosPage() {
  const usuarios = await db.usuario.findMany({ where: { activo: true } });
  return (
    <ul>
      {usuarios.map(u => <li key={u.id}>{u.nombre}</li>)}
    </ul>
  );
}
```

### Client Component

```tsx
"use client";
import { useState } from 'react';

export function ContadorInteractivo() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(c => c + 1)}>Clicks: {count}</button>
  );
}
```

### Composicion correcta: RSC envuelve Client

```tsx
// app/dashboard/page.tsx (Server Component)
import { BotonFiltro } from '@/components/BotonFiltro'; // Client Component
import { db } from '@/lib/db';

export default async function DashboardPage() {
  const datos = await db.reporte.findMany();
  return <BotonFiltro datosIniciales={datos} />;
}
```

---

## Data Fetching — RSC Fetch (preferido)

```tsx
async function getDatos() {
  const res = await fetch('https://api.ejemplo.com/datos', {
    next: { revalidate: 60 },   // ISR: revalidar cada 60 segundos
  });
  if (!res.ok) throw new Error('Error al obtener datos');
  return res.json();
}
```

---

## State Management — Cuando usar que

| Herramienta | Cuando |
|-------------|--------|
| `useState` | Estado local de UI simple |
| `useMemo` | Valores derivados/computados |
| **Zustand** | Estado global ligero compartido entre componentes |
| **Jotai** | Estado atomico con dependencias complejas |
| **React Query** | Cache de datos del servidor en Client Components |

---

## Performance

### React.memo — evitar re-renders innecesarios

```tsx
const TarjetaUsuario = React.memo(function TarjetaUsuario({ usuario }: Props) {
  return <div>{usuario.nombre}</div>;
});
```

### React.lazy + Suspense — code splitting

```tsx
const EditorRich = lazy(() => import('@/components/EditorRich'));

function Pagina() {
  return (
    <Suspense fallback={<div>Cargando editor...</div>}>
      <EditorRich />
    </Suspense>
  );
}
```

---

## Anti-patrones Criticos

```tsx
// MAL: useEffect para transformar datos
const [filtrados, setFiltrados] = useState([]);
useEffect(() => {
  setFiltrados(usuarios.filter(u => u.activo));
}, [usuarios]);

// BIEN: useMemo
const filtrados = useMemo(() => usuarios.filter(u => u.activo), [usuarios]);
```

### Sidebar/menu con RBAC: ocultar secciones cuyos items quedan todos filtrados

**Problema** (L-138 SIGM 2026-05-12): un sidebar con secciones colapsables filtra items por permiso del usuario. Si un usuario sin el permiso requerido ve la sección con su header y chevron pero al expandirla aparece un `<ul>` vacío, el bug confunde — el usuario reporta "el menú no despliega nada", sin saber si es bug del sistema o falta data. Caso SIGM: sección "Ventanilla" tenía 1 item con permiso `CAJA:cobrar`; cuando ningún usuario tenía ese string literal (era `RECAUDACION:CAJA:cobrar`), todos los usuarios veían la sección vacía.

```tsx
// MAL — header se renderiza aunque el <ul> quede vacío
{NAV_SECTIONS.map((section) => (
  <Section key={section.title}>
    <SectionHeader title={section.title} />
    <ul>
      {section.items
        .filter((item) => !item.permiso || tienePermiso(item.permiso))
        .map((item) => <NavItem key={item.href} {...item} />)}
    </ul>
  </Section>
))}
```

```tsx
// BIEN — filtrar items ANTES de decidir si renderizar la sección
{NAV_SECTIONS.map((section) => {
  const itemsVisibles = section.items.filter(
    (item) => !item.permiso || tienePermiso(item.permiso),
  )
  if (itemsVisibles.length === 0) return null  // ← omitir sección entera

  return (
    <Section key={section.title}>
      <SectionHeader title={section.title} />
      <ul>
        {itemsVisibles.map((item) => <NavItem key={item.href} {...item} />)}
      </ul>
    </Section>
  )
})}
```

**Aplica también a**: tabs, breadcrumbs, dropdowns de filtros, paneles de configuración con secciones por permiso. Patrón: cualquier UI que agrupa items en secciones donde los items pueden filtrarse por RBAC.

**Test obligatorio**: cuando el mock de `useAuth` NO incluye los permisos de una sección, esa sección NO debe renderizarse en el DOM (`expect(queryByText('NombreSección')).not.toBeInTheDocument()`).

```tsx
// MAL: fetch en Client Component cuando podria ser RSC
useEffect(() => {
  fetch('/api/datos').then(r => r.json()).then(setDatos);
}, []);

// BIEN: Server Component con fetch directo
const datos = await db.datos.findMany();
```

```tsx
// MAL: key con index en lista mutable
{items.map((item, index) => <Item key={index} {...item} />)}

// BIEN: key con ID estable
{items.map(item => <Item key={item.id} {...item} />)}
```

```tsx
// MAL: componente importa librería de terceros directamente
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';

export function Checkout() {
    const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY!);
    return <Elements stripe={stripe}>...</Elements>;
}

// BIEN: adapter hook con interfaz estable — el componente no conoce Stripe
import { usePagos } from '@/hooks/usePagos';

export function Checkout() {
    const { iniciarSesion, estado } = usePagos();
    return <FormularioPago onSubmit={iniciarSesion} estado={estado} />;
}
```

Toda API de terceros (Stripe, MapBox, analytics, browser APIs complejas) debe
envolverse en un hook adapter con interfaz estable. El componente NUNCA importa
la librería directamente — esto permite cambiar el proveedor sin tocar la UI.

---

Para ejemplos completos de React Query (mutations + invalidacion), Server Actions con Zod, Zustand con immer, Jotai atomico, Next.js App Router (estructura, route handlers, metadata, streaming con Suspense), ver [recursos/patrones-y-ejemplos-completos.md](recursos/patrones-y-ejemplos-completos.md).

## Gotchas / Errores comunes no obvios

**Un Client Component que importa un Server Component lo convierte en Client Component silenciosamente**: si `CartaUsuario.tsx` tiene `"use client"` e importa `FotoAvatar.tsx` (Server Component), Next.js convierte `FotoAvatar` a Client Component sin advertencia. Causa: el boundary `"use client"` se propaga hacia abajo en el árbol de importaciones. Fix: para pasar Server Components a Client Components, usar el patrón `children` o `slot` — el Client Component recibe el Server Component como prop `children`, no lo importa directamente.

**`useEffect` con array de dependencias vacío ejecuta dos veces en React 18 en modo desarrollo (StrictMode)**: React 18 con StrictMode monta, desmonta y vuelve a montar cada componente en desarrollo para detectar efectos no idempotentes. Una petición en `useEffect(fn, [])` puede dispararse dos veces. Causa: comportamiento intencional de StrictMode para detectar limpieza de efectos incorrecta. Fix: asegurarse de que el effect tiene función de cleanup correcta (ej: `return () => controller.abort()`); en producción solo se ejecuta una vez.

**Server Action que actualiza datos sin `revalidatePath` o `revalidateTag` muestra datos obsoletos**: después de un Server Action exitoso (crear/actualizar/eliminar), el cliente sigue viendo el caché anterior del RSC. Causa: Next.js cachea agresivamente los datos del servidor; los Server Actions no invalidan el caché automáticamente. Fix: llamar `revalidatePath('/ruta/afectada')` o `revalidateTag('tag-del-dato')` al final del Server Action antes de `redirect()` o `return`.

**`useState` con objeto como valor inicial no se actualiza con shallow comparison en re-renders del padre**: `useState({ nombre: '', email: '' })` inicializa el estado una sola vez — si el componente padre pasa nuevos valores como prop para reinicializar el formulario, el estado no se actualiza. Causa: `useState` solo usa el valor inicial en el primer render. Fix: usar `key` en el componente para forzar remonte cuando cambien los datos base, o usar `useEffect` con las props como dependencias para sincronizar explícitamente.

**Patrón `useState + useEffect(() => setState(true), [])` para detectar cliente dispara la regla `react-hooks/set-state-in-effect` en eslint-plugin-react-hooks v7+**: build CI falla con `Calling setState synchronously within an effect body causes cascading renders`. Causa: la regla nueva (Next.js 16+) detecta el patrón clásico de "mounted flag" para hidratación segura como anti-patrón de performance. Fix idiomático React 19: usar `useSyncExternalStore`:
```tsx
const mounted = useSyncExternalStore(
  () => () => {},        // subscribe — no-op (la 'store' no cambia tras hidratación)
  () => true,            // getSnapshot — cliente siempre montado
  () => false,           // getServerSnapshot — SSR siempre 'no montado'
)
```
Equivalente funcional al patrón mounted flag, sin disparar la regla. Type-safe, lint-compliant. Usar en Header/Sidebar/cualquier componente que lea localStorage o `window` y necesite render distinto en SSR vs cliente.

**`setState` dentro de `useEffect` por cualquier razón dispara la misma regla — 3 patrones idiomáticos para reemplazarlo** [CONFIRMADO x3 en SIGM Opción C, F1.2/F1.3/F3]: la regla `react-hooks/set-state-in-effect` no solo afecta al patrón mounted flag — bloquea cualquier `setX(...)` síncrono dentro del cuerpo de un `useEffect`. Patrones de reemplazo según el caso:

```tsx
// ❌ Anti-patrón (la regla lo bloquea)
const [value, setValue] = useState(null)
useEffect(() => {
  setValue(cargarDesdeStorage())
}, [])

// ✅ Caso 1 — lazy initialization (estado inicial computado una sola vez)
const [value, setValue] = useState(() => cargarDesdeStorage())

// ✅ Caso 2 — valor derivado de props o estado
const value = useMemo(() => calcular(prop1, prop2), [prop1, prop2])

// ✅ Caso 3 — detección cliente vs SSR
const isClient = useSyncExternalStore(() => () => {}, () => true, () => false)
```

Regla práctica: si el `setState` solo se ejecuta una vez al montar, casi siempre puede reescribirse como `useState(() => init())` o `useMemo(...)`. Solo dejar `setState` dentro de `useEffect` cuando responde a un evento externo asíncrono (fetch, subscription) — en ese caso, mover la lógica a un handler dedicado y aplicar `// eslint-disable-next-line react-hooks/set-state-in-effect` con comentario justificando.
