Skip to content

React de 0 a Profesional — Guía Completa 2025

Tutorial

50 min read

1. ¿Por qué React en 2025?

React sigue siendo la librería de UI más usada del mundo, pero en 2025 el ecosistema ha madurado enormemente:

  • React 19 introduce acciones, hooks de formulario y mejoras masivas al modelo de concurrencia.
  • React Server Components permiten renderizar componentes en el servidor sin enviar JS al cliente.
  • El paradigma ha pasado de "SPA pura" a arquitecturas híbridas (streaming, partial hydration, islands).
  • Frameworks como Next.js 15, Remix y TanStack Start construyen sobre React para darte lo mejor de servidor y cliente.

¿Qué aprenderás aquí? No solo la API de React, sino cómo pensar en componentes, arquitectura escalable, rendimiento real y prácticas que usan equipos profesionales.


2. Entorno de desarrollo profesional

2.1 Requisitos

node --version   # >= 20 LTS recomendado
pnpm --version   # pnpm > npm/yarn por velocidad y eficiencia de disco

2.2 Crear un proyecto moderno

# Con Next.js 15 (recomendado para proyectos reales)
pnpm create next-app@latest mi-proyecto --typescript --tailwind --eslint --app

# Con Vite (para SPAs o librerías)
pnpm create vite@latest mi-proyecto -- --template react-ts
cd mi-proyecto && pnpm install

2.3 Configuración ESLint + Prettier profesional

pnpm add -D eslint-config-prettier prettier eslint-plugin-react-hooks eslint-plugin-jsx-a11y

.eslintrc.json:

{
  "extends": [
    "next/core-web-vitals",
    "plugin:jsx-a11y/recommended",
    "prettier"
  ],
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn",
    "no-console": ["warn", { "allow": ["warn", "error"] }]
  }
}

.prettierrc:

{
  "semi": false,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2
}

2.4 Path aliases (tsconfig.json)

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"],
      "@hooks/*": ["./src/hooks/*"],
      "@lib/*": ["./src/lib/*"]
    }
  }
}

Buena práctica: Usa siempre imports absolutos con alias. import { Button } from '@/components/ui' es mucho más mantenible que '../../../../components/ui'.


3. Fundamentos sólidos de React

3.1 El modelo mental correcto

React es una función de tu estado: UI = f(state). Cada vez que el estado cambia, React recalcula qué partes del DOM deben actualizarse. Interioriza esto antes de memorizar APIs.

3.2 Componentes funcionales modernos

// ❌ Mal: componente que hace demasiado
function UserPage() {
  const [user, setUser] = useState(null)
  const [posts, setPosts] = useState([])
  // 200 líneas de lógica mezclada con UI...
}

// ✅ Bien: separación de responsabilidades
function UserPage({ userId }: { userId: string }) {
  return (
    <main>
      <UserProfile userId={userId} />
      <UserPosts userId={userId} />
    </main>
  )
}

3.3 Props: tipado y valores por defecto

interface ButtonProps {
  label: string
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  isLoading?: boolean
  onClick?: () => void
  children?: React.ReactNode
}

function Button({
  label,
  variant = 'primary',
  size = 'md',
  isLoading = false,
  onClick,
  children,
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={isLoading}
      onClick={onClick}
      aria-busy={isLoading}
    >
      {isLoading ? <Spinner /> : (children ?? label)}
    </button>
  )
}

3.4 Composición sobre configuración

// ❌ Mal: prop drilling y configuración excesiva
<Card showHeader showFooter footerText="..." headerTitle="..." />

// ✅ Bien: composición con compound components
<Card>
  <Card.Header>Mi título</Card.Header>
  <Card.Body>Contenido flexible</Card.Body>
  <Card.Footer>Footer personalizado</Card.Footer>
</Card>

3.5 Keys en listas: hazlo bien desde el inicio

// ❌ Nunca uses el índice como key en listas dinámicas
items.map((item, index) => <Item key={index} {...item} />)

// ✅ Usa un identificador único y estable
items.map((item) => <Item key={item.id} {...item} />)

4. Hooks en profundidad

4.1 useState: cuándo y cómo

// ✅ Agrupar estado relacionado en un objeto
const [form, setForm] = useState({
  name: '',
  email: '',
  role: 'user' as const,
})

// Actualizar campos individualmente sin perder los demás
const handleChange = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
  setForm(prev => ({ ...prev, [field]: e.target.value }))
}

4.2 useEffect: las reglas de oro

// ❌ Efecto sin cleanup (memory leak)
useEffect(() => {
  const subscription = subscribe(userId)
}, [userId])

// ✅ Siempre limpia tus efectos
useEffect(() => {
  const subscription = subscribe(userId)
  return () => subscription.unsubscribe()
}, [userId])

// ✅ Evita efectos para derivar estado — usa useMemo
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName])
// NO hagas esto con useEffect + setState para el mismo resultado

🔑 Regla: Si puedes calcular algo durante el render, no uses useEffect. Los efectos son para sincronizar con sistemas externos (APIs, suscripciones, DOM manual).

4.3 useReducer: para estado complejo

type State = {
  status: 'idle' | 'loading' | 'success' | 'error'
  data: User[] | null
  error: string | null
}

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: User[] }
  | { type: 'FETCH_ERROR'; payload: string }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, status: 'loading', error: null }
    case 'FETCH_SUCCESS':
      return { status: 'success', data: action.payload, error: null }
    case 'FETCH_ERROR':
      return { status: 'error', data: null, error: action.payload }
    default:
      return state
  }
}

function UserList() {
  const [state, dispatch] = useReducer(reducer, {
    status: 'idle',
    data: null,
    error: null,
  })
  // ...
}

4.4 useCallback y useMemo: úsalos con criterio

// ❌ Sobreoptimización — useCallback en callbacks simples sin pasar a hijos memoizados
const handleClick = useCallback(() => setCount(c => c + 1), [])

// ✅ useCallback vale cuando pasas la función a un componente memoizado
const handleSubmit = useCallback(async (data: FormData) => {
  await submitForm(data)
}, [submitForm])

// ✅ useMemo para cálculos costosos
const sortedAndFilteredItems = useMemo(() => {
  return items
    .filter(item => item.category === selectedCategory)
    .sort((a, b) => a.name.localeCompare(b.name))
}, [items, selectedCategory])

4.5 useRef: más allá de acceso al DOM

function Stopwatch() {
  const [time, setTime] = useState(0)
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)

  const start = () => {
    intervalRef.current = setInterval(() => setTime(t => t + 1), 1000)
  }

  const stop = () => {
    if (intervalRef.current) clearInterval(intervalRef.current)
  }

  return (
    <div>
      <p>{time}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  )
}

4.6 Custom Hooks: la joya de React

// hooks/useLocalStorage.ts
function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue
    try {
      const item = window.localStorage.getItem(key)
      return item ? (JSON.parse(item) as T) : initialValue
    } catch {
      return initialValue
    }
  })

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value
      setStoredValue(valueToStore)
      window.localStorage.setItem(key, JSON.stringify(valueToStore))
    } catch (error) {
      console.error(error)
    }
  }

  return [storedValue, setValue] as const
}

// Uso
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')

5. Arquitectura y modularidad

5.1 Estructura de carpetas profesional

src/
├── app/                    # Rutas (Next.js App Router)
│   ├── (auth)/
│   │   ├── login/
│   │   └── register/
│   ├── dashboard/
│   └── layout.tsx
│
├── components/
│   ├── ui/                 # Componentes primitivos reutilizables
│   │   ├── Button/
│   │   │   ├── Button.tsx
│   │   │   ├── Button.test.tsx
│   │   │   └── index.ts
│   │   ├── Input/
│   │   └── Modal/
│   │
│   └── features/           # Componentes de dominio
│       ├── UserProfile/
│       ├── Dashboard/
│       └── Checkout/
│
├── hooks/                  # Custom hooks globales
├── lib/                    # Utilidades, configuraciones
│   ├── api/
│   ├── validations/
│   └── constants.ts
│
├── stores/                 # Estado global (Zustand, etc.)
├── types/                  # TypeScript types/interfaces
└── styles/

5.2 Barrel exports: organiza tus módulos

// components/ui/index.ts
export { Button } from './Button/Button'
export { Input } from './Input/Input'
export { Modal } from './Modal/Modal'
export type { ButtonProps } from './Button/Button'

// Uso limpio en cualquier parte del proyecto
import { Button, Input, Modal } from '@/components/ui'

5.3 Feature-based architecture (para apps grandes)

src/features/
├── auth/
│   ├── components/
│   ├── hooks/
│   ├── api/
│   ├── store/
│   └── index.ts           # API pública del feature
│
├── products/
│   ├── components/
│   ├── hooks/
│   └── index.ts

Regla de oro: Un feature no debería importar de otro feature directamente. Si necesitas compartir algo, muévelo a shared/ o lib/.

5.4 Componentes desacoplados con inversión de dependencias

// ❌ Mal: componente acoplado a la implementación
function ProductList() {
  const products = useProductStore() // acoplado a Zustand
  // ...
}

// ✅ Bien: componente recibe datos por props
function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </ul>
  )
}

// El contenedor gestiona de dónde vienen los datos
function ProductListContainer() {
  const products = useProductStore()
  return <ProductList products={products} />
}

6. Gestión de estado moderna

6.1 La regla de estado mínimo

Antes de añadir estado, pregúntate:

  1. ¿Puede derivarse de otros datos? → useMemo, no useState
  2. ¿Solo lo necesita este componente? → useState local
  3. ¿Lo comparten varios componentes cercanos? → Lifting state up
  4. ¿Es estado del servidor? → TanStack Query / SWR
  5. ¿Es estado global de UI? → Zustand / Jotai

6.2 Zustand: gestión de estado global

pnpm add zustand
// stores/useUserStore.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface User {
  id: string
  name: string
  email: string
}

interface UserStore {
  user: User | null
  isAuthenticated: boolean
  setUser: (user: User) => void
  logout: () => void
}

export const useUserStore = create<UserStore>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        isAuthenticated: false,
        setUser: (user) => set({ user, isAuthenticated: true }),
        logout: () => set({ user: null, isAuthenticated: false }),
      }),
      { name: 'user-storage' }
    )
  )
)

6.3 Context API: para estado de configuración

// No uses Context para estado que cambia frecuentemente
// Úsalo para: tema, idioma, usuario autenticado, feature flags

interface ThemeContextType {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

const ThemeContext = createContext<ThemeContextType | null>(null)

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')
  const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light')

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  const ctx = useContext(ThemeContext)
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
  return ctx
}

7. Fetching de datos y Server State

7.1 TanStack Query: el estándar de facto

pnpm add @tanstack/react-query @tanstack/react-query-devtools
// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,  // 5 minutos
      retry: 2,
      refetchOnWindowFocus: false,
    },
  },
})
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

const USERS_KEY = ['users'] as const

async function fetchUsers(): Promise<User[]> {
  const res = await fetch('/api/users')
  if (!res.ok) throw new Error('Error al cargar usuarios')
  return res.json()
}

export function useUsers() {
  return useQuery({
    queryKey: USERS_KEY,
    queryFn: fetchUsers,
  })
}

export function useCreateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (data: CreateUserDTO) => {
      const res = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      })
      if (!res.ok) throw new Error('Error al crear usuario')
      return res.json()
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: USERS_KEY })
    },
  })
}
// Uso en componente
function UserDashboard() {
  const { data: users, isLoading, isError, error } = useUsers()
  const createUser = useCreateUser()

  if (isLoading) return <UserListSkeleton />
  if (isError) return <ErrorMessage message={error.message} />

  return (
    <>
      <UserList users={users} />
      <button onClick={() => createUser.mutate({ name: 'Ana', email: 'ana@example.com' })}>
        Añadir usuario
      </button>
    </>
  )
}

7.2 Optimistic Updates

useMutation({
  mutationFn: toggleLike,
  onMutate: async (postId) => {
    await queryClient.cancelQueries({ queryKey: ['posts'] })
    const previous = queryClient.getQueryData(['posts'])
    
    queryClient.setQueryData(['posts'], (old: Post[]) =>
      old.map(p => p.id === postId ? { ...p, liked: !p.liked } : p)
    )
    
    return { previous }
  },
  onError: (_err, _vars, context) => {
    queryClient.setQueryData(['posts'], context?.previous)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] })
  },
})

8. Rendimiento y optimización

8.1 React.memo: memoización de componentes

// Usa memo solo cuando el componente es costoso Y sus props cambian poco
const ExpensiveChart = React.memo(function Chart({ data, config }: ChartProps) {
  // renderizado costoso
}, (prevProps, nextProps) => {
  // Comparación personalizada (opcional)
  return prevProps.data === nextProps.data && prevProps.config.type === nextProps.config.type
})

8.2 Code Splitting con lazy

import { lazy, Suspense } from 'react'

// El bundle de estos componentes se carga solo cuando se necesitan
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const Analytics = lazy(() => import('./pages/Analytics'))

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  )
}

8.3 Virtualización para listas largas

pnpm add @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60, // altura estimada de cada item
  })

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: virtualItem.start,
              width: '100%',
              height: virtualItem.size,
            }}
          >
            <ItemRow item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

8.4 Imágenes optimizadas

// En Next.js, siempre usa next/image
import Image from 'next/image'

function Avatar({ user }: { user: User }) {
  return (
    <Image
      src={user.avatarUrl}
      alt={`Avatar de ${user.name}`}
      width={48}
      height={48}
      className="rounded-full"
      priority={false}      // true solo para imágenes above the fold
      placeholder="blur"
      blurDataURL={user.avatarBlurHash}
    />
  )
}

8.5 useTransition para UIs responsivas

// React 18+: marca actualizaciones no urgentes
function SearchPage() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<Result[]>([])
  const [isPending, startTransition] = useTransition()

  const handleSearch = (value: string) => {
    setQuery(value)  // Urgente: actualiza el input inmediatamente
    startTransition(() => {
      setResults(heavySearch(value))  // No urgente: puede interrumpirse
    })
  }

  return (
    <>
      <input value={query} onChange={e => handleSearch(e.target.value)} />
      {isPending && <Spinner />}
      <ResultList results={results} />
    </>
  )
}

9. React Server Components (RSC)

9.1 El nuevo paradigma

Con Next.js App Router, los componentes son Server Components por defecto:

┌─────────────────────────────────────────────────────┐
│ Server Components                                     │
│  ✅ Acceso directo a DB, filesystem, APIs internas   │
│  ✅ No incluyen JS en el bundle del cliente           │
│  ✅ Pueden ser async/await nativamente                │
│  ❌ Sin useState, useEffect, event handlers           │
└─────────────────────────────────────────────────────┘
           ↓ pasan datos a
┌─────────────────────────────────────────────────────┐
│ Client Components  ('use client')                    │
│  ✅ Interactividad, estado, efectos                   │
│  ✅ Acceso a APIs del navegador                       │
│  ❌ No pueden ser async de la misma forma             │
└─────────────────────────────────────────────────────┘

9.2 Componente servidor async

// app/dashboard/page.tsx (Server Component)
async function DashboardPage() {
  // Consulta directa a DB, sin useEffect ni loading states
  const [user, stats] = await Promise.all([
    getUser(),
    getStats(),
  ])

  return (
    <main>
      <h1>Hola, {user.name}</h1>
      {/* StatsChart es un Client Component que recibe datos del servidor */}
      <StatsChart data={stats} />
    </main>
  )
}

9.3 Streaming con Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <main>
      {/* El header se muestra inmediatamente */}
      <DashboardHeader />
      
      {/* Los datos pesados se cargan en streaming */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection />
      </Suspense>
      
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </main>
  )
}

9.4 Server Actions (React 19 / Next.js 15)

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10),
})

export async function createPost(formData: FormData) {
  const parsed = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!parsed.success) {
    return { error: parsed.error.flatten() }
  }

  await db.post.create({ data: parsed.data })
  revalidatePath('/posts')
  return { success: true }
}
// Uso en componente
'use client'
import { useActionState } from 'react'  // React 19
import { createPost } from '@/app/actions'

function CreatePostForm() {
  const [state, action, isPending] = useActionState(createPost, null)

  return (
    <form action={action}>
      <input name="title" placeholder="Título" />
      <textarea name="content" placeholder="Contenido" />
      {state?.error && <ErrorMessage errors={state.error} />}
      <button disabled={isPending}>
        {isPending ? 'Creando...' : 'Crear post'}
      </button>
    </form>
  )
}

10. Testing profesional

10.1 Stack de testing recomendado

pnpm add -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
  },
})

10.2 Testing de componentes: la filosofía correcta

🎯 Principio: Testea el comportamiento, no la implementación. Tus tests deben parecerse a cómo un usuario usa tu app.

// components/ui/Button/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'

describe('Button', () => {
  it('llama a onClick cuando el usuario hace click', async () => {
    const handleClick = vi.fn()
    render(<Button label="Guardar" onClick={handleClick} />)

    await userEvent.click(screen.getByRole('button', { name: /guardar/i }))

    expect(handleClick).toHaveBeenCalledOnce()
  })

  it('está deshabilitado y muestra spinner cuando isLoading es true', () => {
    render(<Button label="Guardar" isLoading />)

    const button = screen.getByRole('button')
    expect(button).toBeDisabled()
    expect(button).toHaveAttribute('aria-busy', 'true')
  })
})

10.3 Testing de custom hooks

import { renderHook, act } from '@testing-library/react'
import { useLocalStorage } from './useLocalStorage'

describe('useLocalStorage', () => {
  beforeEach(() => window.localStorage.clear())

  it('devuelve el valor inicial cuando no hay datos guardados', () => {
    const { result } = renderHook(() => useLocalStorage('key', 'default'))
    expect(result.current[0]).toBe('default')
  })

  it('persiste el valor en localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('key', ''))
    act(() => result.current[1]('nuevo valor'))
    expect(localStorage.getItem('key')).toBe('"nuevo valor"')
  })
})

10.4 Testing de integración con MSW

pnpm add -D msw
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: '1', name: 'Ana García', email: 'ana@example.com' },
    ])
  }),
  http.post('/api/users', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json({ id: '2', ...body }, { status: 201 })
  }),
]

11. Accesibilidad (a11y)

11.1 Por qué importa

La accesibilidad no es opcional. Es un requisito legal en muchos países y beneficia a todos los usuarios (teclado, lectores de pantalla, dispositivos táctiles, conexiones lentas).

11.2 Reglas fundamentales

// ❌ Mal: div clickable sin semántica
<div onClick={handleDelete} className="icon-btn">🗑️</div>

// ✅ Bien: botón con label accessible
<button
  onClick={handleDelete}
  aria-label="Eliminar elemento"
  type="button"
>
  <TrashIcon aria-hidden="true" />
  <span className="sr-only">Eliminar</span>
</button>
// ✅ Formularios accesibles
<div>
  <label htmlFor="email">
    Email
    <span aria-label="requerido" className="text-red-500">*</span>
  </label>
  <input
    id="email"
    type="email"
    name="email"
    required
    aria-describedby={error ? 'email-error' : undefined}
    aria-invalid={!!error}
  />
  {error && (
    <p id="email-error" role="alert" className="text-red-500 text-sm">
      {error}
    </p>
  )}
</div>

11.3 Gestión del foco

// Mueve el foco cuando abres un modal
function Modal({ isOpen, onClose, children }: ModalProps) {
  const closeButtonRef = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    if (isOpen) closeButtonRef.current?.focus()
  }, [isOpen])

  if (!isOpen) return null

  return (
    <div role="dialog" aria-modal="true" aria-labelledby="modal-title">
      <h2 id="modal-title">Título del modal</h2>
      {children}
      <button ref={closeButtonRef} onClick={onClose}>Cerrar</button>
    </div>
  )
}

12. Patrones avanzados

12.1 Render Props (cuando tiene sentido)

interface DataFetcherProps<T> {
  url: string
  render: (data: T, isLoading: boolean) => React.ReactNode
}

function DataFetcher<T>({ url, render }: DataFetcherProps<T>) {
  const { data, isLoading } = useQuery({ queryKey: [url], queryFn: () => fetch(url).then(r => r.json()) })
  return <>{render(data, isLoading)}</>
}

// Uso
<DataFetcher<User[]>
  url="/api/users"
  render={(users, isLoading) => isLoading ? <Spinner /> : <UserList users={users} />}
/>

12.2 Compound Components

const AccordionContext = createContext<{ openId: string | null; toggle: (id: string) => void } | null>(null)

function Accordion({ children }: { children: React.ReactNode }) {
  const [openId, setOpenId] = useState<string | null>(null)
  const toggle = (id: string) => setOpenId(prev => prev === id ? null : id)

  return (
    <AccordionContext.Provider value={{ openId, toggle }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  )
}

function AccordionItem({ id, title, children }: AccordionItemProps) {
  const { openId, toggle } = useContext(AccordionContext)!
  const isOpen = openId === id

  return (
    <div>
      <button
        onClick={() => toggle(id)}
        aria-expanded={isOpen}
        aria-controls={`panel-${id}`}
      >
        {title}
        <ChevronIcon className={isOpen ? 'rotate-180' : ''} aria-hidden />
      </button>
      <div id={`panel-${id}`} hidden={!isOpen} role="region">
        {children}
      </div>
    </div>
  )
}

Accordion.Item = AccordionItem

// Uso limpio
<Accordion>
  <Accordion.Item id="1" title="¿Qué es React?">Contenido...</Accordion.Item>
  <Accordion.Item id="2" title="¿Para qué sirve?">Contenido...</Accordion.Item>
</Accordion>

12.3 Error Boundaries

'use client'

class ErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback: React.ReactNode },
  { hasError: boolean; error: Error | null }
> {
  constructor(props: any) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    // Envía el error a tu servicio de monitoreo (Sentry, etc.)
    reportError(error, info.componentStack)
  }

  render() {
    if (this.state.hasError) return this.props.fallback
    return this.props.children
  }
}

// Uso
<ErrorBoundary fallback={<ErrorPage />}>
  <Dashboard />
</ErrorBoundary>

12.4 HOC (Higher Order Components)

// Patrón útil para inyectar comportamiento transversal
function withAuthGuard<T extends object>(Component: React.ComponentType<T>) {
  return function AuthGuardedComponent(props: T) {
    const { isAuthenticated } = useUserStore()
    const router = useRouter()

    useEffect(() => {
      if (!isAuthenticated) router.push('/login')
    }, [isAuthenticated, router])

    if (!isAuthenticated) return <LoadingScreen />
    return <Component {...props} />
  }
}

// Uso
const ProtectedDashboard = withAuthGuard(Dashboard)

13. TypeScript con React

13.1 Tipos esenciales

// Tipos de componentes
type FC<P = {}> = React.FunctionComponent<P>

// Props con children
interface LayoutProps {
  children: React.ReactNode  // Prefiere ReactNode a ReactElement
}

// Eventos
type ButtonClickHandler = React.MouseEventHandler<HTMLButtonElement>
type InputChangeHandler = React.ChangeEventHandler<HTMLInputElement>

// Refs
const inputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)

13.2 Tipos genéricos para componentes reutilizables

interface SelectProps<T> {
  options: T[]
  value: T | null
  onChange: (value: T) => void
  getLabel: (option: T) => string
  getValue: (option: T) => string
}

function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
  return (
    <select
      value={value ? getValue(value) : ''}
      onChange={e => {
        const selected = options.find(o => getValue(o) === e.target.value)
        if (selected) onChange(selected)
      }}
    >
      {options.map(option => (
        <option key={getValue(option)} value={getValue(option)}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  )
}

// Inferencia automática del tipo
<Select<User>
  options={users}
  value={selectedUser}
  onChange={setSelectedUser}
  getLabel={u => u.name}
  getValue={u => u.id}
/>

13.3 Discriminated Unions para props

type AlertProps =
  | { variant: 'info' | 'success'; message: string }
  | { variant: 'error'; message: string; onRetry?: () => void }
  | { variant: 'warning'; message: string; onDismiss: () => void }

function Alert(props: AlertProps) {
  return (
    <div className={`alert alert-${props.variant}`}>
      <p>{props.message}</p>
      {props.variant === 'error' && props.onRetry && (
        <button onClick={props.onRetry}>Reintentar</button>
      )}
      {props.variant === 'warning' && (
        <button onClick={props.onDismiss}>Descartar</button>
      )}
    </div>
  )
}

14. CI/CD y despliegue

14.1 GitHub Actions: pipeline básico

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm type-check
      - run: pnpm test --run
      - run: pnpm build

14.2 Variables de entorno tipadas

// lib/env.ts — con Zod para validar en build time
import { z } from 'zod'

const envSchema = z.object({
  NEXT_PUBLIC_API_URL: z.string().url(),
  DATABASE_URL: z.string().min(1),
  AUTH_SECRET: z.string().min(32),
})

export const env = envSchema.parse({
  NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  DATABASE_URL: process.env.DATABASE_URL,
  AUTH_SECRET: process.env.AUTH_SECRET,
})
// Si falta alguna variable, el build falla con un mensaje claro ✅

14.3 Análisis del bundle

# Next.js
pnpm add -D @next/bundle-analyzer

# En next.config.ts
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})

# Ejecutar
ANALYZE=true pnpm build

15. Checklist del desarrollador profesional

Antes de hacer merge de cualquier PR

  • El componente tiene una sola responsabilidad
  • Los tipos de TypeScript están bien definidos (sin any)
  • Los hooks de React respetan las reglas (eslint no da warnings)
  • No hay console.log accidentales
  • Las listas tienen keys estables y únicas
  • Los efectos tienen cleanup cuando corresponde
  • Los formularios son accesibles (labels, aria, roles)
  • Hay tests para la lógica principal
  • Las imágenes tienen alt text descriptivo
  • No hay imports circulares

Para features nuevas

  • ¿Necesito este estado o puedo derivarlo?
  • ¿El componente escala? ¿Qué pasa con 0 items? ¿Con 10.000?
  • ¿Hay loading state, error state y empty state?
  • ¿Funciona en móvil?
  • ¿Funciona solo con teclado?

Para optimización

  • ¿El problema está medido antes de optimizar?
  • ¿El code splitting está aplicado en rutas pesadas?
  • ¿Las listas largas están virtualizadas?
  • ¿Las consultas tienen staleTime adecuado?
  • ¿Los Core Web Vitals están en verde?

🚀 Recursos para seguir aprendiendo

RecursoTipoPor qué
react.devDocumentación oficialLa mejor referencia, con ejemplos interactivos
TanStack Query docsDocsImprescindible para fetching de datos
Next.js docsDocsEl framework más completo para React
Kent C. Dodds BlogBlogTesting y buenas prácticas de alto nivel
Josh ComeauBlogCSS y React explicados de forma visual
Tao of ReactLibro gratuitoFilosofía y arquitectura de React

Recuerda: La habilidad más importante no es conocer todas las APIs de React, sino saber cuándo no usarlas. Escribe el código más simple que funcione, mide antes de optimizar, y testa el comportamiento no la implementación.

Tutorial actualizado para React 19, Next.js 15 y el ecosistema de 2025.