GUIDE

React: Arquitectura Frontend Moderna

Guía técnica profunda sobre arquitectura de componentes React, hooks, gestión de estado, renderizado del lado del servidor, estrategias de testing y optimización de rendimiento. Basada en la construcción de WEBGYM-V2, la principal aplicación web orientada al cliente.

ReactHooksNext.jsReduxZustandReact QueryJotaiReact Hook FormReact RouterPlaywrightstyled-componentsTesting Library

Índice de Contenidos

  1. 1. Componentes, Hooks y Context
  2. 2. Estrategias de Gestión de Estado
  3. 3. Server-Side Rendering con Next.js
  4. 4. React 19+ y Funcionalidades Concurrentes
  5. 5. Formularios con React Hook Form
  6. 6. Routing con React Router
  7. 7. Testing de Aplicaciones React
  8. 8. Optimización de Rendimiento
  9. 9. Styled-Components y CSS-in-JS

1. Componentes, Hooks y Context

Principios de Diseño de Componentes

Un componente React bien diseñado sigue el principio de responsabilidad única: renderiza una pieza de UI y gestiona el estado directamente relacionado con esa UI. Los componentes deben categorizarse en componentes presentacionales (renderizado puro, sin efectos secundarios) y componentes contenedores (fetching de datos, lógica de negocio). Con hooks, esta distinción se implementa a través de custom hooks en lugar de HOCs o render props.

// Presentational component: pure rendering
interface MemberCardProps {
  member: Member;
  subscription: Subscription | null;
  onRenew: (memberId: string) => void;
}

function MemberCard({ member, subscription, onRenew }: MemberCardProps) {
  const isExpired = subscription?.expiresAt
    ? new Date(subscription.expiresAt) < new Date()
    : true;

  return (
    <Card>
      <Avatar src={member.photoUrl} alt={member.name} />
      <Name>{member.name}</Name>
      <Status expired={isExpired}>
        {isExpired ? 'Expired' : `Active until ${formatDate(subscription!.expiresAt)}`}
      </Status>
      {isExpired && <RenewButton onClick={() => onRenew(member.id)}>Renew</RenewButton>}
    </Card>
  );
}

Custom Hooks: Extrayendo Lógica Reutilizable

Los custom hooks encapsulan lógica con estado que puede compartirse entre componentes. Un custom hook es solo una función que llama a otros hooks. La convención de nombres use* señaliza a React (y al linter) que aplican las reglas de hooks. Los custom hooks bien diseñados retornan referencias estables y minimizan re-renders en los componentes que los consumen.use* signals to React (and the linter) that the rules of hooks apply. Well-designed custom hooks return stable references and minimize re-renders in consuming components.

// Custom hook: debounced search with abort controller
function useSearch<T>(searchFn: (query: string, signal: AbortSignal) => Promise<T[]>) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<T[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (!query.trim()) { setResults([]); return; }

    const controller = new AbortController();
    const timeoutId = setTimeout(async () => {
      setIsLoading(true);
      setError(null);
      try {
        const data = await searchFn(query, controller.signal);
        setResults(data);
      } catch (e) {
        if (!controller.signal.aborted) setError(e as Error);
      } finally {
        if (!controller.signal.aborted) setIsLoading(false);
      }
    }, 300); // 300ms debounce

    return () => { clearTimeout(timeoutId); controller.abort(); };
  }, [query, searchFn]);

  return { query, setQuery, results, isLoading, error } as const;
}

Context: Cuándo y Cuándo No Usarlo

React Context resuelve el prop drilling pero introduce una trampa de rendimiento: cada componente que consume un context se re-renderiza cuando el valor del context cambia, sin importar qué parte del valor usa. Divide los contexts por frecuencia de actualización (un ThemeContext que cambia raramente vs. un UserContext que cambia en eventos de auth). Para estado de alta frecuencia como inputs de formulario o animaciones, context es la herramienta equivocada. Usa Zustand, Jotai o prop passing directo.ThemeContext that changes rarely vs. a UserContext that changes on auth events). For high-frequency state like form inputs or animations, context is the wrong tool. Use Zustand, Jotai, or direct prop passing instead.

// Split context pattern: separate value from dispatch
const AuthStateContext = createContext<AuthState | null>(null);
const AuthDispatchContext = createContext<AuthDispatch | null>(null);

function AuthProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, initialAuthState);

  // Memoize to prevent unnecessary re-renders
  const stateValue = useMemo(() => state, [state]);

  return (
    <AuthStateContext.Provider value={stateValue}>
      <AuthDispatchContext.Provider value={dispatch}>
        {children}
      </AuthDispatchContext.Provider>
    </AuthStateContext.Provider>
  );
}

// Components that only dispatch actions don't re-render on state changes
function LogoutButton() {
  const dispatch = useContext(AuthDispatchContext)!;
  return <button onClick={() => dispatch({ type: 'LOGOUT' })}>Logout</button>;
}

2. Estrategias de Gestión de Estado

Eligiendo la Herramienta Correcta

La gestión de estado en React no es un problema de solución única. La decisión depende del tipo de estado: estado de UI local (useState), estado de UI cross-component (Zustand/Jotai), estado de cache del servidor (React Query/SWR), estado de URL (React Router) y estado de formulario (React Hook Form). Mezclar estas preocupaciones en un solo store (como los patrones tempranos de Redux fomentaban) crea complejidad innecesaria y re-renders.

Redux Toolkit

Redux Toolkit (RTK) es la configuración oficial y opinada de Redux. Elimina boilerplate a través de createSlice (reducers + actions), createAsyncThunk (operaciones asíncronas) y createEntityAdapter (estado normalizado). RTK Query agrega fetching de datos y caching integrados. Redux sigue siendo relevante para aplicaciones con estado complejo del lado del cliente que múltiples componentes leen y escriben: carritos de compra, formularios multi-paso, estado de colaboración en tiempo real.createSlice (reducers + actions), createAsyncThunk (async operations), and createEntityAdapter (normalized state). RTK Query adds built-in data fetching and caching. Redux remains relevant for applications with complex client-side state that multiple components read and write: shopping carts, multi-step forms, real-time collaboration state.

Zustand

Zustand provee un store mínimo sin el boilerplate de Redux. Usa una API simple basada en hooks y soporta middleware (persistencia, devtools, immer). La ventaja clave son las suscripciones basadas en selectores: los componentes solo se re-renderizan cuando el slice específico de estado que seleccionan cambia, a diferencia de Context que dispara re-renders ante cualquier cambio.

// Zustand store with typed selectors and middleware
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface GymStore {
  members: Member[];
  selectedMemberId: string | null;
  filters: { status: 'all' | 'active' | 'expired'; search: string };
  setFilter: (key: keyof GymStore['filters'], value: string) => void;
  selectMember: (id: string | null) => void;
  addMember: (member: Member) => void;
}

const useGymStore = create<GymStore>()(
  devtools(
    persist(
      immer((set) => ({
        members: [],
        selectedMemberId: null,
        filters: { status: 'all', search: '' },
        setFilter: (key, value) => set((state) => { state.filters[key] = value; }),
        selectMember: (id) => set((state) => { state.selectedMemberId = id; }),
        addMember: (member) => set((state) => { state.members.push(member); }),
      })),
      { name: 'gym-store' }
    )
  )
);

// Component: only re-renders when filters change, not when members change
function FilterBar() {
  const filters = useGymStore((s) => s.filters);
  const setFilter = useGymStore((s) => s.setFilter);
  // ...
}

React Query (TanStack Query)

React Query trata el estado del servidor como una preocupación separada del estado del cliente. Maneja caching, refetching en segundo plano, stale-while-revalidate, paginación, scroll infinito y actualizaciones optimistas. El modelo mental: el servidor es la fuente de verdad, y React Query sincroniza un cache local con él. Esto elimina la necesidad de almacenar datos obtenidos en Redux o Context.

// React Query: typed hooks for API data
const memberKeys = {
  all: ['members'] as const,
  lists: () => [...memberKeys.all, 'list'] as const,
  list: (filters: MemberFilters) => [...memberKeys.lists(), filters] as const,
  details: () => [...memberKeys.all, 'detail'] as const,
  detail: (id: string) => [...memberKeys.details(), id] as const,
};

function useMembers(filters: MemberFilters) {
  return useQuery({
    queryKey: memberKeys.list(filters),
    queryFn: () => api.members.list(filters),
    staleTime: 5 * 60 * 1000,     // 5 minutes
    placeholderData: keepPreviousData, // keep old data while fetching new page
  });
}

function useUpdateMember() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data: UpdateMemberDto) => api.members.update(data),
    onSuccess: (updated) => {
      // Invalidate list queries and update the detail cache
      queryClient.invalidateQueries({ queryKey: memberKeys.lists() });
      queryClient.setQueryData(memberKeys.detail(updated.id), updated);
    },
  });
}

Jotai: Gestión de Estado Atómica

Jotai toma un enfoque bottom-up para la gestión de estado usando átomos como unidad primitiva. Cada átomo contiene una pieza de estado, y los átomos derivados computan valores a partir de otros átomos. Los componentes se suscriben solo a los átomos que leen, asegurando re-renders mínimos. Jotai sobresale para estado granular que Context maneja pobremente: toggles, selecciones, filtros distribuidos entre muchos componentes.

// Jotai: atomic state with derived atoms
import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// Primitive atoms
const membersAtom = atom<Member[]>([]);
const searchQueryAtom = atom('');
const statusFilterAtom = atomWithStorage<'all' | 'active' | 'expired'>('statusFilter', 'all');

// Derived atom: automatically recomputes when dependencies change
const filteredMembersAtom = atom((get) => {
  const members = get(membersAtom);
  const query = get(searchQueryAtom).toLowerCase();
  const status = get(statusFilterAtom);

  return members.filter((m) => {
    const matchesQuery = m.name.toLowerCase().includes(query) || m.email.toLowerCase().includes(query);
    const matchesStatus = status === 'all' || m.status === status;
    return matchesQuery && matchesStatus;
  });
});

// Async atom: fetches data and populates the members atom
const fetchMembersAtom = atom(null, async (get, set) => {
  const response = await fetch('/api/members');
  const data = await response.json();
  set(membersAtom, data);
});

// Component: only re-renders when filtered results change
function MemberSearch() {
  const [query, setQuery] = useAtom(searchQueryAtom);
  const filtered = useAtomValue(filteredMembersAtom);
  // ...
}

3. Server-Side Rendering con Next.js

Next.js provee tres estrategias de renderizado: Static Site Generation (SSG) en tiempo de build, Server-Side Rendering (SSR) en cada request, e Incremental Static Regeneration (ISR) que combina ambos. El App Router (Next.js 13+) introduce React Server Components (RSC) que se renderizan en el servidor y envían solo el HTML al cliente, eliminando bundles JavaScript para contenido no interactivo.

React Server Components

Los Server Components se ejecutan exclusivamente en el servidor. Pueden acceder directamente a bases de datos, sistema de archivos y variables de entorno sin exponerlos al cliente. Producen cero JavaScript del lado del cliente. Los Client Components (marcados con "use client") manejan la interactividad. La frontera entre server y client components es la decisión arquitectónica que determina el tamaño del bundle y el rendimiento."use client") handle interactivity. The boundary between server and client components is the architectural decision that determines bundle size and performance.

// app/members/page.tsx - Server Component (default in App Router)
import { db } from '@/lib/database';
import { MemberList } from './member-list'; // Client Component

// This runs on the server only - db access is safe here
export default async function MembersPage({
  searchParams,
}: {
  searchParams: { page?: string; status?: string };
}) {
  const page = parseInt(searchParams.page ?? '1');
  const status = searchParams.status ?? 'all';

  // Direct database query - no API route needed
  const { members, total } = await db.members.findMany({
    where: status !== 'all' ? { status } : undefined,
    skip: (page - 1) * 20,
    take: 20,
  });

  // MemberList is a Client Component that handles interaction
  return (
    <main>
      <h1>Members ({total})</h1>
      <MemberList members={members} total={total} currentPage={page} />
    </main>
  );
}

Patrones de Data Fetching

En el App Router, el data fetching ocurre en Server Components usando async/await. Next.js extiende el fetch nativo para agregar caching y revalidación. Usa cache: "force-cache" para datos estáticos (equivalente a SSG), cache: "no-store" para datos dinámicos (equivalente a SSR), y next: { revalidate: 60 } para ISR con una ventana de revalidación de 60 segundos.async/await. Next.js extends the native fetch to add caching and revalidation. Use cache: 'force-cache' for static data (equivalent to SSG), cache: 'no-store' for dynamic data (equivalent to SSR), and next: { revalidate: 60 } for ISR with a 60-second revalidation window.

4. React 19+ y Funcionalidades Concurrentes

Suspense para Data Fetching

React 18 extiende Suspense más allá de componentes lazy-loaded para soportar data fetching. Un boundary de Suspense envuelve componentes que pueden suspenderse mientras cargan datos, mostrando una UI de fallback hasta que los datos se resuelven. Los boundaries de Suspense anidados permiten estados de carga granulares: el shell de la página puede aparecer instantáneamente mientras las secciones individuales cargan independientemente.Suspense beyond lazy-loaded components to support data fetching. A Suspense boundary wraps components that may suspend while loading data, displaying a fallback UI until the data resolves. Nested Suspense boundaries allow fine-grained loading states: a page shell can appear instantly while individual sections load independently.

// Nested Suspense boundaries for granular loading states
function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />  {/* Suspends while fetching stats */}
      </Suspense>
      <div className="grid">
        <Suspense fallback={<MemberListSkeleton />}>
          <RecentMembers />  {/* Suspends independently */}
        </Suspense>
        <Suspense fallback={<ActivitySkeleton />}>
          <ActivityFeed />  {/* Suspends independently */}
        </Suspense>
      </div>
    </main>
  );
}

useTransition y useDeferredValue

useTransition marks state updates as non-urgent, allowing React to keep the UI responsive during expensive re-renders. The isPending flag lets you show a subtle loading indicator without replacing visible content. useDeferredValue achieves a similar effect for values: it returns a deferred copy that lags behind the actual value, letting React prioritize urgent updates like typing.

// useTransition: keep search input responsive while filtering large lists
function MemberDirectory({ members }: { members: Member[] }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
    // Urgent: update the input immediately
    setQuery(e.target.value);

    // Non-urgent: filter the list in the background
    startTransition(() => {
      setFilteredMembers(
        members.filter((m) => m.name.toLowerCase().includes(e.target.value.toLowerCase()))
      );
    });
  };

  return (
    <>
      <input value={query} onChange={handleSearch} placeholder="Search members..." />
      {isPending && <Spinner size="sm" />}
      <MemberList members={filteredMembers} />
    </>
  );
}

// useDeferredValue: defer expensive rendering
function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const results = useMemo(
    () => expensiveSearch(deferredQuery),
    [deferredQuery]
  );

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      {results.map((r) => <ResultCard key={r.id} result={r} />)}
    </div>
  );
}

Patrones de Rendering Concurrente

El rendering concurrente permite a React preparar múltiples versiones de la UI simultáneamente y alternar entre ellas sin bloquear. Los patrones clave son: (1) envolver navegaciones en startTransition para evitar mostrar una página en blanco durante cambios de ruta, (2) usar Suspense con streaming SSR en Next.js para enviar HTML progresivamente, y (3) combinar useDeferredValue con componentes memoizados para mantener listas costosas responsivas durante el input del usuario.startTransition to avoid showing a blank page during route changes, (2) using Suspense with streaming SSR in Next.js to send HTML progressively, and (3) combining useDeferredValue with memoized components to keep expensive lists responsive during user input.

En producción, WEBGYM-V2 adoptó las funcionalidades concurrentes de React 18 para la vista de horarios de clases. El horario renderizaba 500+ franjas horarias a lo largo de 7 días. Usar useTransition para cambios de rango de fechas y useDeferredValue para búsqueda de instructores mantuvo la UI responsiva mientras el pesado grid del calendario se re-renderizaba en segundo plano.

React 19: Nuevos Hooks y Patrones (Estable Dic 2024)

React 19 introduce nuevos hooks que simplifican patrones comunes: useActionState gestiona el estado de envío de formularios (pendiente, error, resultado) en un solo hook. useOptimistic proporciona actualizaciones optimistas de UI que se revierten en caso de fallo. useFormStatus lee el estado pendiente del formulario padre desde cualquier componente hijo. La API use() lee promesas y contexto directamente en el render, reemplazando muchos patrones de carga de datos basados en useEffect. El rendering concurrente es ahora el valor por defecto para todas las actualizaciones.useActionState manages form submission state (pending, error, result) in a single hook. useOptimistic provides optimistic UI updates that revert on failure. useFormStatus reads the parent form's pending state from any child component. The use() API reads promises and context directly in render, replacing many useEffect-based data loading patterns. Concurrent rendering is now the default for all updates.

// React 19: useActionState for form submissions
import { useActionState } from 'react';

function RenewMembership({ memberId }: { memberId: string }) {
  const [state, submitAction, isPending] = useActionState(
    async (prevState, formData: FormData) => {
      const plan = formData.get('plan') as string;
      const result = await renewMembership(memberId, plan);
      return { success: true, message: `Renewed: ${result.plan}` };
    },
    { success: false, message: '' }
  );

  return (
    <form action={submitAction}>
      <select name="plan">
        <option value="monthly">Monthly</option>
        <option value="annual">Annual</option>
      </select>
      <button disabled={isPending}>
        {isPending ? 'Renewing...' : 'Renew'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}

// React 19: useOptimistic for instant UI feedback
import { useOptimistic } from 'react';

function MemberList({ members }: { members: Member[] }) {
  const [optimisticMembers, addOptimistic] = useOptimistic(
    members,
    (current, updatedMember: Member) =>
      current.map(m => m.id === updatedMember.id ? updatedMember : m)
  );

  async function toggleStatus(member: Member) {
    const updated = { ...member, status: member.status === 'active' ? 'inactive' : 'active' };
    addOptimistic(updated); // Instant UI update
    await updateMemberStatus(member.id, updated.status); // Server call
  }

  return optimisticMembers.map(m => (
    <MemberCard key={m.id} member={m} onToggle={() => toggleStatus(m)} />
  ));
}

// React 19: ref as prop (no more forwardRef)
function Input({ ref, ...props }: { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />;
}

Server Actions: Reemplazando Rutas de API

Los Server Actions (estables en React 19 / Next.js 14+) permiten llamar funciones del servidor directamente desde componentes cliente usando la directiva "use server". Reemplazan endpoints REST/GraphQL para mutaciones, reduciendo el código de envío de formularios en un 50-70%. Los Server Actions se integran con useActionState y useFormStatus para estados de carga y error incorporados, y soportan mejora progresiva (los formularios funcionan sin JavaScript)."use server" directive. They replace REST/GraphQL endpoints for mutations, reducing form submission code by 50-70%. Server Actions integrate with useActionState and useFormStatus for built-in loading and error states, and support progressive enhancement (forms work without JavaScript).

// Server Action: runs on server, called from client
'use server';

export async function createMember(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  // Direct database access - no API route needed
  const member = await db.member.create({ data: { name, email } });
  revalidatePath('/members');
  return { success: true, id: member.id };
}

// Client component using the Server Action
'use client';
import { useFormStatus } from 'react-dom';
import { createMember } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Creating...' : 'Create Member'}</button>;
}

function NewMemberForm() {
  return (
    <form action={createMember}>
      <input name="name" required />
      <input name="email" type="email" required />
      <SubmitButton />
    </form>
  );
}

React Compiler v1.0 (Oct 2025)

El React Compiler (v1.0, octubre 2025) realiza memoización automática en tiempo de build, eliminando la necesidad de useMemo, useCallback y React.memo manuales. El compilador analiza las funciones de render de los componentes y determina automáticamente qué valores y componentes pueden memoizarse. Los benchmarks muestran cargas iniciales mediblemente más rápidas e interacciones significativamente más rápidas en UIs complejas. Es un plugin de Babel que no requiere cambios de código para adoptar.useMemo, useCallback, and React.memo. The compiler analyzes component render functions and automatically determines which values and components can be memoized. Benchmarks show measurably faster initial page loads and significantly quicker interactions on complex UIs. It is a Babel plugin that requires zero code changes to adopt.

// Before React Compiler: manual memoization everywhere
function MemberDashboard({ members, onSelect }) {
  const activeMembers = useMemo(
    () => members.filter(m => m.status === 'active'),
    [members]
  );
  const handleSelect = useCallback(
    (id) => onSelect(id),
    [onSelect]
  );
  return <MemberList members={activeMembers} onSelect={handleSelect} />;
}
const MemberList = React.memo(({ members, onSelect }) => { /* ... */ });

// After React Compiler: write natural code, compiler handles memoization
function MemberDashboard({ members, onSelect }) {
  const activeMembers = members.filter(m => m.status === 'active');
  return <MemberList members={activeMembers} onSelect={(id) => onSelect(id)} />;
}
function MemberList({ members, onSelect }) { /* ... */ }
// Compiler auto-memoizes both components, the filter, and the callback
React 19 + el React Compiler representan el mayor cambio en patrones de desarrollo React desde los hooks. Los Server Actions colapsan la frontera cliente-servidor para mutaciones, useActionState/useOptimistic reemplazan la mayoría de la gestión de estado de carga/error, y el compilador elimina la memoización como preocupación manual. Proyectos nuevos deberían adoptar estos patrones desde el inicio; proyectos existentes pueden migrar incrementalmente.

5. Formularios con React Hook Form

React Hook Form minimiza los re-renders usando inputs no controlados con registro basado en refs. A diferencia de librerías de formularios controlados que re-renderizan en cada tecla, React Hook Form solo dispara re-renders en envío del formulario o cambios de validación. Para formularios complejos con muchos campos, esta diferencia es medible: un formulario de membresía de 30 campos re-renderiza 30 veces por tecla con inputs controlados vs. cero veces con React Hook Form.

Registro y Validación

// React Hook Form with Zod schema validation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const memberSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  phone: z.string().regex(/^\+?[1-9]\d{6,14}$/, 'Invalid phone number'),
  plan: z.enum(['basic', 'premium', 'vip']),
  startDate: z.string().refine((d) => new Date(d) >= new Date(), {
    message: 'Start date must be in the future',
  }),
});

type MemberFormData = z.infer<typeof memberSchema>;

function MemberRegistrationForm({ onSubmit }: { onSubmit: (data: MemberFormData) => void }) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
  } = useForm<MemberFormData>({
    resolver: zodResolver(memberSchema),
    defaultValues: { plan: 'basic' },
  });

  const submit = async (data: MemberFormData) => {
    await onSubmit(data);
    reset();
  };

  return (
    <form onSubmit={handleSubmit(submit)}>
      <label>
        Name
        <input {...register('name')} />
        {errors.name && <span role="alert">{errors.name.message}</span>}
      </label>
      <label>
        Email
        <input type="email" {...register('email')} />
        {errors.email && <span role="alert">{errors.email.message}</span>}
      </label>
      <label>
        Plan
        <select {...register('plan')}>
          <option value="basic">Basic</option>
          <option value="premium">Premium</option>
          <option value="vip">VIP</option>
        </select>
      </label>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Registering...' : 'Register Member'}
      </button>
    </form>
  );
}

Patrones Avanzados: Campos Dinámicos y Watch

Para formularios multi-paso o formularios con arrays de campos dinámicos, React Hook Form provee useFieldArray para agregar/eliminar secciones repetibles y watch para dependencias reactivas entre campos. El hook useFormContext comparte estado del formulario a través de componentes profundamente anidados sin prop drilling, combinándose bien con patrones de compound components.useFieldArray for adding/removing repeatable sections and watch for reactive field dependencies. The useFormContext hook shares form state across deeply nested components without prop drilling, combining well with compound component patterns.

En producción, el formulario de registro de membresía de WEBGYM-V2 tenía 28 campos a lo largo de 4 pasos. Migrar de Formik (controlado) a React Hook Form (no controlado) redujo los re-renders por tecla de 28 a 0 y mejoró la responsividad percibida del input en dispositivos Android de gama baja usados en las recepciones de los gimnasios.

6. Routing con React Router

React Router v8 (lanzado el 17 de junio de 2026, la versión major actual) mantiene la estructura de rutas anidadas que refleja el árbol de componentes. Las rutas se definen declarativamente con elementos o la API del data router (createBrowserRouter). El data router soporta loaders (obtener datos antes de renderizar), actions (manejar envíos de formulario) y error boundaries a nivel de ruta. v8 completa la consolidación iniciada en v7: el paquete react-router-dom se eliminó (importa desde react-router y react-router/dom), los paquetes se publican solo en ESM, el middleware de rutas viene activado por defecto y se mantiene el modo framework opcional con routing basado en archivos, code splitting y SSR heredados de Remix. Requiere Node 22.22.0+, React 19.2.7+ y Vite 7+.

Data Router y Loaders

// React Router v7 data router with loaders and error boundaries
import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <GlobalError />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'members',
        element: <MembersLayout />,
        children: [
          {
            index: true,
            element: <MemberList />,
            loader: async ({ request }) => {
              const url = new URL(request.url);
              const status = url.searchParams.get('status') ?? 'all';
              return api.members.list({ status });
            },
          },
          {
            path: ':memberId',
            element: <MemberDetail />,
            loader: async ({ params }) => api.members.get(params.memberId!),
            errorElement: <MemberNotFound />,
          },
        ],
      },
      {
        path: 'admin',
        element: <AdminLayout />,
        loader: requireAuth,  // redirect if not authenticated
        children: [
          { path: 'reports', element: <Reports />, lazy: () => import('./pages/Reports') },
        ],
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

Rutas Protegidas y Guards

La protección de rutas puede implementarse a través de loaders que verifican el estado de autenticación y redirigen, o a través de componentes wrapper que renderizan rutas condicionalmente. El enfoque de loader es preferido porque se ejecuta antes de que el componente se renderice, previniendo el flash de contenido no autorizado. Combina con la utilidad defer de React Router para transmitir layouts autenticados mientras se cargan datos específicos del usuario.defer utility to stream authenticated layouts while loading user-specific data.

7. Testing de Aplicaciones React

React Testing Library: Testeando Comportamiento, No Implementación

React Testing Library (RTL) impone testear desde la perspectiva del usuario. En lugar de testear estado interno o llamadas a métodos, RTL consulta elementos por rol accesible, texto, label y placeholder. El principio guía: "Cuanto más se parezcan tus tests a cómo se usa tu software, más confianza te dan." Evita testear detalles de implementación como valores de estado o métodos de instancia del componente.

// Testing a subscription renewal flow
describe('MemberCard', () => {
  it('shows renewal button when subscription is expired', () => {
    const onRenew = jest.fn();
    render(
      <MemberCard
        member={mockMember}
        subscription={{ ...mockSubscription, expiresAt: '2024-01-01' }}
        onRenew={onRenew}
      />
    );

    expect(screen.getByText('Expired')).toBeInTheDocument();
    const renewBtn = screen.getByRole('button', { name: /renew/i });
    expect(renewBtn).toBeInTheDocument();

    fireEvent.click(renewBtn);
    expect(onRenew).toHaveBeenCalledWith(mockMember.id);
  });

  it('shows active status when subscription is valid', () => {
    render(
      <MemberCard
        member={mockMember}
        subscription={{ ...mockSubscription, expiresAt: '2030-12-31' }}
        onRenew={jest.fn()}
      />
    );

    expect(screen.getByText(/active until/i)).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /renew/i })).not.toBeInTheDocument();
  });
});

Testeando Custom Hooks

Usa renderHook de RTL para testear custom hooks en aislamiento. Para hooks que dependen de providers (React Query, Redux, Context), envuélvelos en un wrapper personalizado. Para hooks con efectos asíncronos, usa waitFor para aserciones sobre el estado final después de que todas las actualizaciones se hayan resuelto.renderHook from RTL to test custom hooks in isolation. For hooks that depend on providers (React Query, Redux, Context), wrap them in a custom wrapper. For hooks with async effects, use waitFor to assert on the final state after all updates have settled.

// Testing a custom hook with async behavior
describe('useSearch', () => {
  it('debounces search queries and returns results', async () => {
    const searchFn = jest.fn().mockResolvedValue([{ id: '1', name: 'Result' }]);
    const { result } = renderHook(() => useSearch(searchFn));

    act(() => { result.current.setQuery('test'); });

    // Should not call immediately (debounced)
    expect(searchFn).not.toHaveBeenCalled();

    // Wait for debounce timeout + async resolution
    await waitFor(() => {
      expect(searchFn).toHaveBeenCalledWith('test', expect.any(AbortSignal));
      expect(result.current.results).toHaveLength(1);
      expect(result.current.isLoading).toBe(false);
    });
  });

  it('aborts previous request when query changes', async () => {
    const searchFn = jest.fn().mockImplementation(
      (query, signal) => new Promise((resolve, reject) => {
        signal.addEventListener('abort', () => reject(new DOMException('Aborted')));
        setTimeout(() => resolve([{ id: query }]), 500);
      })
    );

    const { result } = renderHook(() => useSearch(searchFn));
    act(() => { result.current.setQuery('first'); });
    act(() => { result.current.setQuery('second'); });

    await waitFor(() => {
      expect(result.current.results).toEqual([{ id: 'second' }]);
    });
  });
});

Testing de Integración con MSW

Mock Service Worker (MSW) intercepta requests HTTP a nivel de red, habilitando tests de integración realistas sin mockear fetch o axios. Define handlers que repliquen tu API, y los tests ejercitan el árbol completo de componentes incluyendo data fetching, estados de carga, manejo de errores e interacciones del usuario.

Testing End-to-End con Playwright

Playwright testea la aplicación como lo haría un usuario real, ejecutando un navegador completo (Chromium, Firefox o WebKit). A diferencia de Cypress, Playwright se ejecuta fuera del navegador, habilitando testing multi-pestaña, descargas de archivos y emulación nativa de móviles. Para aplicaciones React, Playwright complementa a RTL: RTL testea comportamiento de componentes en aislamiento, mientras Playwright valida flujos completos del usuario entre páginas.

// Playwright: E2E test for member registration flow
import { test, expect } from '@playwright/test';

test.describe('Member Registration', () => {
  test('registers a new member and shows confirmation', async ({ page }) => {
    await page.goto('/members/register');

    // Fill the form
    await page.getByLabel('Name').fill('Maria Garcia');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Phone').fill('+573001234567');
    await page.getByLabel('Plan').selectOption('premium');

    // Submit and verify
    await page.getByRole('button', { name: 'Register Member' }).click();
    await expect(page.getByText('Registration successful')).toBeVisible();
    await expect(page).toHaveURL(/\/members\/[a-z0-9-]+/);
  });

  test('shows validation errors for invalid inputs', async ({ page }) => {
    await page.goto('/members/register');
    await page.getByRole('button', { name: 'Register Member' }).click();

    await expect(page.getByText('Name must be at least 2 characters')).toBeVisible();
    await expect(page.getByText('Invalid email address')).toBeVisible();
  });
});

8. Optimización de Rendimiento

Previniendo Re-Renders Innecesarios

Cada render en React crea un nuevo árbol de elementos que se reconcilia con el árbol anterior. Aunque el algoritmo de diffing de React es rápido, los renders innecesarios se acumulan y causan jank. Las herramientas principales son: React.memo (saltar re-render si los props son superficialmente iguales), useMemo (cachear valores computados) y useCallback (cachear referencias a funciones). Úsalos estratégicamente, no en todas partes.React.memo (skip re-render if props are shallowly equal), useMemo (cache computed values), and useCallback (cache function references). Use them strategically, not everywhere.

// Optimized list rendering with memoization
const MemberRow = memo(function MemberRow({ member, onSelect }: MemberRowProps) {
  // Only re-renders when member object or onSelect reference changes
  return (
    <tr onClick={() => onSelect(member.id)}>
      <td>{member.name}</td>
      <td>{member.email}</td>
      <td>{formatDate(member.joinedAt)}</td>
    </tr>
  );
});

function MemberTable({ members }: { members: Member[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);

  // Stable reference: does not change between renders
  const handleSelect = useCallback((id: string) => {
    setSelectedId(id);
  }, []);

  // Expensive computation: only recalculates when members array changes
  const sortedMembers = useMemo(
    () => [...members].sort((a, b) => a.name.localeCompare(b.name)),
    [members]
  );

  return (
    <table>
      <tbody>
        {sortedMembers.map((m) => (
          <MemberRow key={m.id} member={m} onSelect={handleSelect} />
        ))}
      </tbody>
    </table>
  );
}

Code Splitting y Lazy Loading

El code splitting reduce el tamaño del bundle inicial cargando módulos bajo demanda. Usa React.lazy con Suspense para splitting basado en rutas. Para componentes debajo del fold, usa IntersectionObserver para disparar la carga cuando el componente entra al viewport. Next.js maneja el splitting por rutas automáticamente; el splitting adicional debe apuntar a árboles de componentes grandes o librerías pesadas.React.lazy with Suspense for route-based splitting. For below-the-fold components, use IntersectionObserver to trigger loading when the component enters the viewport. Next.js handles route-based splitting automatically; additional splitting should target large component trees or heavy libraries.

// Route-based code splitting
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const MemberProfile = lazy(() => import('./pages/MemberProfile'));
const Reports = lazy(() => import('./pages/Reports'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/members/:id" element={<MemberProfile />} />
        <Route path="/reports" element={<Reports />} />
      </Routes>
    </Suspense>
  );
}

Virtualización para Listas Grandes

Para listas con cientos o miles de ítems, renderizar todos los nodos DOM causa degradación severa del rendimiento. Librerías como @tanstack/react-virtual renderizan solo los ítems visibles más un pequeño buffer de overscan. El DOM contiene 20-50 elementos en lugar de 10,000, reduciendo el uso de memoria y mejorando el rendimiento de scroll dramáticamente.@tanstack/react-virtual render only the visible items plus a small overscan buffer. The DOM contains 20-50 elements instead of 10,000, reducing memory usage and improving scroll performance dramatically.

En producción, la página de lista de miembros de WEBGYM-V2 renderizaba hasta 5,000 miembros en una sola vista. Implementar virtualización con react-virtual redujo los nodos DOM de 5,000+ filas a ~30 filas visibles, bajando el tiempo de render inicial de 2.4s a 80ms y eliminando el jank de scroll por completo.

9. Styled-Components y CSS-in-JS

Las librerías CSS-in-JS como styled-components colocan los estilos junto a los componentes, habilitando estilos dinámicos basados en props y valores del tema. Los estilos se scoped automáticamente (sin colisiones de nombres de clase), y los estilos no usados se eliminan con tree-shaking durante el build. El trade-off es overhead en runtime: styled-components genera CSS en runtime, lo que agrega al bundle JavaScript y al costo de ejecución.

Patrones de Styled-Components

// Theme-aware styled components with TypeScript
import styled, { css } from 'styled-components';

interface Theme {
  colors: { primary: string; bg: string; text: string; muted: string };
  spacing: (n: number) => string;
  borderRadius: string;
}

const Card = styled.div<{ elevated?: boolean }>`
  background: ${({ theme }) => theme.colors.bg};
  border-radius: ${({ theme }) => theme.borderRadius};
  padding: ${({ theme }) => theme.spacing(3)};

  ${({ elevated }) => elevated && css`
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
    border: 1px solid ${({ theme }) => theme.colors.primary}22;
  `}
`;

const StatusBadge = styled.span<{ status: 'active' | 'expired' | 'pending' }>`
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 600;

  ${({ status, theme }) => {
    const colors = {
      active: { bg: '#dcfce7', text: '#166534' },
      expired: { bg: '#fee2e2', text: '#991b1b' },
      pending: { bg: '#fef3c7', text: '#92400e' },
    };
    return css`
      background: ${colors[status].bg};
      color: ${colors[status].text};
    `;
  }}
`;

Consideraciones de Rendimiento

Para aplicaciones donde la generación de CSS en runtime es un cuello de botella, considera alternativas zero-runtime: vanilla-extract (TypeScript-first, extraído en build), Tailwind CSS (clases utilitarias, purgadas en producción), o CSS Modules (nombres de clase scoped, sin runtime). Para WEBGYM-V2, styled-components funcionó bien porque la cantidad de componentes era manejable y el requerimiento de cambio de tema justificaba la generación de estilos en runtime.vanilla-extract (TypeScript-first, extracted at build time), Tailwind CSS (utility classes, purged in production), or CSS Modules (scoped class names, no runtime). For WEBGYM-V2, styled-components performed well because the component count was manageable and the theme switching requirement justified runtime style generation.

En producción, styled-components fue la elección correcta para WEBGYM-V2 porque la aplicación soportaba tematización white-label: cada gimnasio personalizaba colores, fuentes y espaciado a través de un objeto de tema. La generación de CSS en runtime permitía cambios de tema sin reconstruir la aplicación. Para el panel de administración (Angular), usamos SCSS ya que la tematización no era un requerimiento.

Últimas Actualizaciones (Julio 2026)

React 19.1: Owner Stacks y Mejoras en Suspense

React 19.1 (28 de marzo de 2025) introdujo Owner Stacks, una funcionalidad de debugging solo para desarrollo que rastrea qué componentes son responsables de renderizar un componente particular vía captureOwnerStack(). A diferencia de los Component Stacks que muestran la jerarquía del DOM, los Owner Stacks muestran la cadena de renderizado -- invaluable para depurar árboles complejos. React 19.1 también expandió el soporte de Suspense a través de las fases de cliente, servidor e hidratación, y mejoró el manejo de errores de streaming en React Server Components.captureOwnerStack(). Unlike Component Stacks that show the DOM hierarchy, Owner Stacks show the rendering chain -- invaluable for debugging complex trees. React 19.1 also expanded Suspense support across client, server, and hydration phases, and improved streaming error handling in React Server Components.

React 19.2.x: Activity API y useEffectEvent

React 19.2 (estable octubre 2025, último parche 19.2.8 a julio 2026) introdujo la Activity API para renderizado offscreen y el hook useEffectEvent, que captura los valores más recientes de props y estado sin agregarlos al array de dependencias del efecto. El React Compiler (v1.0, plugin de Babel) ya está listo para producción y ampliamente adoptado, memoizando componentes automáticamente y eliminando la necesidad de useMemo, useCallback y React.memo manuales. Los benchmarks muestran cargas iniciales y interacciones mediblemente más rápidas en UIs complejas sin ningún cambio de código.useEffectEvent hook, which captures the latest values of props and state without adding them to the effect dependency array. The React Compiler (v1.0, Babel plugin) is now production-ready and widely adopted, automatically memoizing components and eliminating the need for manual useMemo, useCallback, and React.memo. Benchmarks show measurably faster initial page loads and interactions on complex UIs with zero code changes required.

Patrones de Server Components en Producción

Los React Server Components son estables en toda la línea 19.x y no romperán entre versiones menores. La utilidad cacheSignal habilita la limpieza de operaciones asíncronas cuando el scope de cache() de un componente expira. Partial Pre-rendering (PPR) permite pre-renderizar contenido estático del shell desde un CDN y continuar con contenido dinámico en el cliente, combinando lo mejor de SSG y SSR. Los Server Actions continúan reemplazando rutas de API tradicionales para mutaciones, reduciendo el código de envío de formularios en un 50-70% con soporte de mejora progresiva.cacheSignal utility enables cleanup of asynchronous operations when a component's cache() scope expires. Partial Pre-rendering (PPR) allows pre-rendering static shell content from a CDN and resuming with dynamic content on the client, combining the best of SSG and SSR. Server Actions continue to replace traditional API routes for mutations, reducing form submission code by 50-70% while supporting progressive enhancement.

Con React 19.2.x estable y el React Compiler listo para producción, el ecosistema React se ha estabilizado en una nueva base: Server Components para data fetching, Server Actions para mutaciones, el Compiler para memoización automática y la Activity API para preparación offscreen. Proyectos nuevos deberían adoptar estos patrones desde el inicio. Proyectos existentes pueden actualizar incrementalmente ya que React 19.2 es un reemplazo directo de 19.0/19.1.

Más Guías