React de 0 a Profesional — Guía Completa 2025
Tutorial
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/olib/.
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:
- ¿Puede derivarse de otros datos? → useMemo, no useState
- ¿Solo lo necesita este componente? → useState local
- ¿Lo comparten varios componentes cercanos? → Lifting state up
- ¿Es estado del servidor? → TanStack Query / SWR
- ¿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
| Recurso | Tipo | Por qué |
|---|---|---|
| react.dev | Documentación oficial | La mejor referencia, con ejemplos interactivos |
| TanStack Query docs | Docs | Imprescindible para fetching de datos |
| Next.js docs | Docs | El framework más completo para React |
| Kent C. Dodds Blog | Blog | Testing y buenas prácticas de alto nivel |
| Josh Comeau | Blog | CSS y React explicados de forma visual |
| Tao of React | Libro gratuito | Filosofí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.