Imagina que el servidor de CicloUrbano devuelve una bicicleta sin el campo precioHora. TarjetaBicicleta intenta hacer bicicleta.precioHora.toFixed(2), JavaScript lanza «Cannot read properties of undefined», y el resultado no es una tarjeta rota: es que toda la aplicación desaparece. Cabecera, catálogo, formulario de reservas, pie de página: pantalla en blanco. Desde React 16, un error no capturado durante el render desmonta el árbol completo, y ese comportamiento —deliberado— hace imprescindible una pieza que aún no conoces: el límite de error. En esta lección aprenderás qué es, cómo se implementa (con la única excepción de React 19 que todavía exige un componente de clase), dónde colocarlo, qué tipos de error no captura y qué hacer en esos casos, y cómo diseñar un mensaje de fallo que no espante a quien lo lee.

Contenido

  1. Qué ocurre hoy cuando un componente revienta
  2. Qué es un límite de error
  3. Los dos métodos: getDerivedStateFromError y componentDidCatch
  4. LimiteDeError completo para CicloUrbano
  5. Dónde colocar los límites: global y granulares
  6. Qué NO capturan los límites de error
  7. Desarrollo frente a producción
  8. react-error-boundary, la alternativa práctica
  9. Registrar el error en un servicio de monitorización
  10. Diseñar un buen mensaje de fallo

  1. Qué ocurre hoy cuando un componente revienta

Vamos a provocarlo a propósito. Añade una bicicleta defectuosa a los datos de CicloUrbano:

// src/datos/dominio.js (solo para la demostración)
export const bicicletaDefectuosa = {
  id: 'bici-999',
  modelo: 'Prototipo',
  tipo: 'urbana',
  estado: 'disponible',
  estacionId: 'est-01'
  // ¡falta precioHora!
};

TarjetaBicicleta la recibe y ejecuta esta línea, que ya escribiste en 02-05:

const precioFormateado = bicicleta.precioHora.toFixed(2).replace('.', ',');
// TypeError: Cannot read properties of undefined (reading 'toFixed')

Lo que ves en el navegador con la aplicación compilada para producción:

(nada — un <div id="root"> completamente vacío)

Y en la consola:

Uncaught TypeError: Cannot read properties of undefined (reading 'toFixed')
The above error occurred in the <TarjetaBicicleta> component.
Consider adding an error boundary to your tree to customize error handling behavior.

¿Por qué React desmonta todo? La decisión se tomó en React 16 y el razonamiento es este: si un componente ha fallado a mitad de render, el árbol queda en un estado inconsistente y desconocido. Dejar la interfaz a medias es peor que quitarla: se podrían mostrar saldos equivocados, botones que ejecutan la acción que no toca o formularios que envían datos corruptos. En una aplicación de pagos, una interfaz corrupta es más peligrosa que ninguna interfaz.

Pero «pantalla en blanco» tampoco es una respuesta aceptable para la persona usuaria. La solución que ofrece React es que decidas qué mostrar en su lugar, y ese es el trabajo del límite de error.

  1. Qué es un límite de error

Un límite de error (error boundary) es un componente que captura los errores de JavaScript lanzados en cualquier punto de su subárbol de hijos, evita que la aplicación entera se desmonte y pinta en su lugar una interfaz alternativa.

Las cuatro propiedades que definen su comportamiento:

  • Captura hacia abajo, no hacia los lados. Protege a sus descendientes, nunca a sus hermanos ni a sí mismo.
  • Se comporta como un catch de JavaScript, pero para componentes. El error «burbujea» hacia arriba por el árbol hasta encontrar el primer límite.
  • Sustituye el subárbol entero. Cuando captura, todo lo que colgaba de él se desmonta y se pinta la alternativa. No hay reparación parcial.
  • Si no hay ningún límite en toda la cadena, el error llega a la raíz y React desmonta la aplicación: la pantalla en blanco del apartado 1.
flowchart TD
    ROOT["main.jsx / createRoot"] --> LG["LimiteDeError GLOBAL"]
    LG --> APP["App"]
    APP --> CAB["Cabecera ✅ sigue viva"]
    APP --> LC["LimiteDeError «Catálogo»"]
    APP --> LR["LimiteDeError «Reservas»"]
    LC --> LIS["ListaBicicletas"]
    LIS --> T1["TarjetaBicicleta"]
    LIS --> T2["TarjetaBicicleta 💥 error"]
    LR --> FR["FormularioReserva ✅ sigue vivo"]
    T2 -. "el error sube" .-> LC
    style LC fill:#fde68a
    style T2 fill:#fecaca

En este árbol, el fallo de una tarjeta lo captura el límite del catálogo: se pierde la lista de bicicletas y se muestra un mensaje en su lugar, pero la cabecera, el formulario de reservas y el pie siguen funcionando. Esa es toda la idea.

  1. Los dos métodos: getDerivedStateFromError y componentDidCatch

Aquí llega la excepción anunciada en 04-03 y 04-04: no existe ningún hook equivalente. Un límite de error tiene que ser un componente de clase, porque los dos métodos que lo hacen posible solo están disponibles en clases. React lo reconoce abiertamente en su documentación, y hay trabajo en curso para ofrecer una alternativa, pero a día de hoy —React 19— la clase es obligatoria.

Los dos métodos hacen cosas distintas y conviene no confundirlos.

static getDerivedStateFromError(error)

static getDerivedStateFromError(error) {
  // Debe devolver un objeto con el nuevo estado, o null para no cambiarlo
  return { hayError: true };
}
Aspecto Detalle
Es static Pertenece a la clase, no a la instancia. No tiene this
Cuándo se ejecuta Durante la fase de render, justo después de que un descendiente lance
Qué recibe El objeto de error lanzado
Qué debe devolver Un objeto de estado (se fusiona con el actual), o null
Para qué sirve Solo para decidir que hay que pintar la alternativa
Qué NO debe hacer Efectos secundarios: nada de fetch, console.log ni registro

La prohibición de efectos secundarios no es una recomendación: este método se ejecuta durante el render, que debe ser puro (04-03), y React puede llamarlo más de una vez para el mismo error. Un registro escrito aquí podría enviarse por duplicado.

componentDidCatch(error, infoDelError)

componentDidCatch(error, infoDelError) {
  // infoDelError.componentStack: el camino de componentes hasta el fallo
  registrarEnServicio(error, infoDelError.componentStack);
}
Aspecto Detalle
Es un método de instancia Sí tiene this: puede leer props y estado
Cuándo se ejecuta Después del render de la alternativa, en la fase de commit
Qué recibe El error y un objeto con componentStack
Para qué sirve Efectos secundarios: registrar el error, avisar a un servicio de monitorización
Qué NO conviene hacer Decidir qué se pinta (para eso está el método anterior)

El componentStack es la información más valiosa que obtendrás de un fallo en producción:

    in TarjetaBicicleta (created by ListaBicicletas)
    in ListaBicicletas (created by PanelAvanzado)
    in PanelAvanzado (created by App)
    in App

No es la pila de llamadas de JavaScript: es el camino de componentes, que te dice exactamente qué parte de la interfaz falló.

Resumen de la división de trabajo: getDerivedStateFromError pinta; componentDidCatch registra. Puedes implementar solo el primero, pero entonces te quedarás sin saber qué falla en producción.

  1. LimiteDeError completo para CicloUrbano

// src/componentes/LimiteDeError.jsx
import { Component } from 'react';
import estilos from './LimiteDeError.module.css';

/**
 * Límite de error de CicloUrbano. ÚNICA clase del proyecto: los dos métodos
 * que necesita no tienen equivalente con hooks en React 19.
 *
 * Props:
 *  - children    (contenido protegido, obligatorio)
 *  - alternativa (JSX o función (error, reintentar) => JSX, opcional)
 *  - titulo      (cadena, opcional): encabezado del mensaje por defecto
 *  - alRegistrar (función, opcional): recibe (error, componentStack)
 */
class LimiteDeError extends Component {
  constructor(props) {
    super(props);
    this.state = { hayError: false, error: null };
    this.reintentar = this.reintentar.bind(this);
  }

  // 1. PINTAR: se ejecuta durante el render. Sin efectos secundarios.
  static getDerivedStateFromError(error) {
    return { hayError: true, error };
  }

  // 2. REGISTRAR: se ejecuta después. Aquí sí van los efectos secundarios.
  componentDidCatch(error, infoDelError) {
    console.error('[LimiteDeError] fallo capturado:', error);
    console.error('[LimiteDeError] componentes:', infoDelError.componentStack);

    if (this.props.alRegistrar) {
      this.props.alRegistrar(error, infoDelError.componentStack);
    }
  }

  // 3. REINTENTAR: volver al estado sano y remontar el subárbol
  reintentar() {
    this.setState({ hayError: false, error: null });
  }

  render() {
    if (!this.state.hayError) {
      return this.props.children;      // caso normal: no estorba en absoluto
    }

    const { alternativa, titulo = 'No hemos podido mostrar esta sección' } = this.props;

    // La alternativa puede ser una función, para recibir el error y el reintento
    if (typeof alternativa === 'function') {
      return alternativa(this.state.error, this.reintentar);
    }

    if (alternativa) {
      return alternativa;
    }

    // Alternativa por defecto
    return (
      <section className={estilos.limite} role="alert">
        <h2 className={estilos.titulo}>{titulo}</h2>
        <p className={estilos.texto}>
          Ha ocurrido un problema técnico y esta parte de CicloUrbano no se ha podido
          cargar. El resto de la página sigue funcionando con normalidad.
        </p>
        <button type="button" className={estilos.boton} onClick={this.reintentar}>
          Volver a intentarlo
        </button>
      </section>
    );
  }
}

export default LimiteDeError;

Repaso de las decisiones de diseño:

  • El estado guarda dos cosas: el indicador hayError para decidir qué pintar y el error en sí, para poder mostrar detalles o pasárselo a la alternativa.
  • render devuelve this.props.children sin tocarlo mientras no hay error. Un límite de error es invisible en el caso normal: no añade marcado, no añade estilos, no cuesta rendimiento.
  • alternativa admite dos formas. Si es JSX, se pinta tal cual; si es una función, se le pasan (error, reintentar) para que quien lo usa pueda construir un mensaje a medida con un botón propio. Es la técnica de render props de 04-02, aplicada donde de verdad aporta.
  • El botón de reintento hace setState({ hayError: false }). Eso provoca un render normal, children vuelve a montarse desde cero y, si la causa era transitoria —una respuesta de red malformada, por ejemplo—, la interfaz se recupera. Si el error es permanente, el límite lo capturará otra vez y se volverá a mostrar el mensaje: no hay bucle infinito, porque el reintento lo dispara una persona.
  • role="alert" viene de 03-06: el mensaje aparece sin que nadie mueva el foco, y las tecnologías de asistencia deben anunciarlo.
  • El bind del constructor es el de 02-02: reintentar se pasa como manejador y perdería su this sin él.

Uso

// src/main.jsx — el límite global
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import LimiteDeError from './componentes/LimiteDeError.jsx';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <LimiteDeError titulo="CicloUrbano no está disponible ahora mismo">
      <App />
    </LimiteDeError>
  </StrictMode>
);
// src/App.jsx — límites granulares
<LimiteDeError
  titulo="No hemos podido cargar el catálogo"
  alternativa={(error, reintentar) => (
    <Aviso
      tono="error"
      titulo="Catálogo no disponible"
      acciones={<button type="button" onClick={reintentar}>Reintentar</button>}
    >
      No hemos podido mostrar las bicicletas. Puedes seguir consultando tus reservas
      mientras lo solucionamos.
    </Aviso>
  )}
>
  <ListaBicicletas
    bicicletas={bicicletasVisibles}
    estaciones={estaciones}
    alSeleccionar={manejarSeleccionBicicleta}
    alReservar={manejarReserva}
  />
</LimiteDeError>

Fíjate en que la alternativa reutiliza el Aviso genérico de 04-02: composición otra vez, sin una línea de estilo duplicada.

  1. Dónde colocar los límites: global y granulares

La estrategia recomendada combina dos niveles.

Un límite global en la raíz. Es la red de última instancia: garantiza que nunca aparezca una pantalla en blanco, pase lo que pase. Envuelve a <App /> en main.jsx y muestra un mensaje de aplicación completa, con la opción de recargar.

Varios límites granulares alrededor de las secciones independientes. La pregunta que decide dónde poner uno es siempre la misma:

¿Qué parte de la interfaz puede perderse sin que la aplicación deje de ser útil?

En CicloUrbano hay tres respuestas claras:

Sección Qué protege Qué sigue funcionando si falla
Catálogo de bicicletas ListaBicicletas y sus tarjetas Cabecera, reservas, estaciones
Panel de reservas PanelReserva y FormularioReserva El catálogo entero se sigue consultando
Mapa de estaciones TarjetaEstacion y sus contadores Catálogo y reservas
flowchart TD
    M["main.jsx"] --> LG["🛡️ LimiteDeError GLOBAL<br/>«CicloUrbano no está disponible»"]
    LG --> APP["App"]
    APP --> CAB["Cabecera<br/>(sin límite: es marcado estático)"]
    APP --> L1["🛡️ LimiteDeError «Catálogo»"]
    APP --> L2["🛡️ LimiteDeError «Reservas»"]
    APP --> L3["🛡️ LimiteDeError «Estaciones»"]
    APP --> PIE["PieDePagina"]
    L1 --> CAT["SelectorTipo + ListaBicicletas + TarjetaBicicleta"]
    L2 --> RSV["PanelReserva + FormularioReserva"]
    L3 --> EST["TarjetaEstacion + ContadorPlazas"]

Criterios prácticos para decidir:

  • Envuelve lo que consume datos externos. Todo lo que depende de una API es candidato: los datos malformados son la causa número uno de errores en render.
  • Envuelve lo que es de terceros. Un gráfico, un mapa, un reproductor: código que no controlas.
  • Envuelve cada ruta. Cuando llegue React Router (Módulo 6), un límite por ruta impide que un fallo en una pantalla tumbe la navegación entera.
  • No envuelvas cada componente. Un límite por tarjeta parece más seguro, pero llena el código de ruido y hace que un fallo se disfrace de «tarjeta rota» en vez de saltar a la vista. La granularidad adecuada es la sección, no el elemento.
  • Recuerda que un límite no se protege a sí mismo. Si el JSX de tu alternativa lanza un error, el fallo sube al límite de arriba. Mantén las alternativas simples: texto, un botón y poco más, sin cálculos ni acceso a campos que podrían faltar.

  1. Qué NO capturan los límites de error

Este apartado es el que evita falsas sensaciones de seguridad. Un límite de error captura solo errores lanzados durante el render, en los métodos del ciclo de vida y en los constructores de su subárbol. Todo lo demás queda fuera.

Tipo de error ¿Lo captura? Qué hacer en su lugar
Render de un descendiente Es su razón de ser
Ciclo de vida de un descendiente
Constructor de un descendiente
Manejadores de eventos (onClick, onSubmit) No try/catch dentro del manejador
Código asíncrono (setTimeout, promesas, fetch) No try/catch con async/await, o .catch()
Renderizado en servidor No Manejo de errores propio del framework (Next.js, 10-01)
Errores del propio límite No Lo captura el límite superior; mantén la alternativa simple

Manejadores de eventos

La razón es sencilla: un manejador se ejecuta fuera del render, cuando React ya no está construyendo nada. Un fallo ahí no deja el árbol inconsistente, así que React no interviene y el error llega a la consola sin más.

// src/App.jsx (fragmento)
function App() {
  const [errorReserva, setErrorReserva] = useState(null);

  async function manejarConfirmacion({ bicicleta, horas, total }) {
    setErrorReserva(null);
    try {
      await enviarReserva({ bicicletaId: bicicleta.id, horas, total });
      setBicicletaSeleccionada(null);
    } catch (error) {
      console.error('[App] no se ha podido crear la reserva', error);
      setErrorReserva('No hemos podido confirmar tu reserva. Inténtalo de nuevo.');
    }
  }

  return (
    <>
      {errorReserva && (
        <Aviso tono="error" titulo="Reserva no confirmada">{errorReserva}</Aviso>
      )}
      {/* … */}
    </>
  );
}

El patrón es siempre el mismo: try/catch en el manejador y un estado de error propio que se pinta con el Aviso de 04-02. Es más trabajo que un límite, pero también permite un mensaje mucho más preciso, porque sabes exactamente qué operación ha fallado.

Código asíncrono

// ROTO: el límite NO captura esto
useEffect(() => {
  setTimeout(() => {
    throw new Error('fallo diferido');   // sale del contexto de React
  }, 1000);
}, []);
// CORRECTO: capturar y convertir en estado
useEffect(() => {
  let cancelado = false;

  async function cargar() {
    try {
      const respuesta = await fetch('/api/bicicletas');
      if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
      const datos = await respuesta.json();
      if (!cancelado) setBicicletas(datos);
    } catch (error) {
      if (!cancelado) setErrorCarga(error.message);
    }
  }

  cargar();
  return () => { cancelado = true; };   // limpieza: la lección 04-03 en acción
}, []);

Existe un truco para llevar un error asíncrono hasta un límite: guardarlo en estado y relanzarlo durante el render.

const [errorFatal, setErrorFatal] = useState(null);
if (errorFatal) {
  throw errorFatal;      // ahora sí ocurre durante el render: el límite lo captura
}

Úsalo con moderación y solo para errores que de verdad impiden mostrar la sección; para lo demás, un estado de error y un Aviso dan mejor experiencia. En el Módulo 7 verás que las bibliotecas de estado del servidor integran esto de serie.

  1. Desarrollo frente a producción

Un detalle que confunde a mucha gente la primera vez que prueba un límite de error: en desarrollo, el error se sigue viendo.

Desarrollo (npm run dev) Producción (npm run build)
El límite captura el error
Se pinta la alternativa
Aparece la superposición de errores de Vite Sí, encima de todo No
El error se imprime en consola Sí, siempre
Mensaje del error Completo, con pila y componentes Puede estar minificado

La superposición roja de Vite se muestra aunque el límite haya funcionado perfectamente, y es intencionado: durante el desarrollo quieres enterarte de todos los errores, no que se los trague un catch silencioso. Ciérrala con Escape y verás debajo tu alternativa, ya pintada.

Para comprobar de verdad el comportamiento que verá el público:

npm run build
npm run preview

En esa compilación no hay superposición, y verás exactamente lo que verá la persona usuaria. Es una prueba que conviene hacer siempre antes de dar por bueno un límite: es fácil escribir una alternativa que a su vez falla —por ejemplo, leyendo error.response.data— y en desarrollo no darse cuenta porque la superposición tapa el resultado.

  1. react-error-boundary, la alternativa práctica

Escribir la clase una vez está bien; escribirla en cada proyecto, no tanto. La biblioteca react-error-boundary es el estándar de facto de la comunidad y encapsula todo lo del apartado 4, además de añadir la parte que más cuesta hacer bien: el reinicio.

npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';

function AlternativaCatalogo({ error, resetErrorBoundary }) {
  return (
    <Aviso
      tono="error"
      titulo="Catálogo no disponible"
      acciones={<button type="button" onClick={resetErrorBoundary}>Reintentar</button>}
    >
      No hemos podido mostrar las bicicletas ({error.message}).
    </Aviso>
  );
}

// Uso
<ErrorBoundary
  FallbackComponent={AlternativaCatalogo}
  onError={(error, info) => registrarEnServicio(error, info.componentStack)}
  onReset={() => recargarBicicletas()}
  resetKeys={[tipoElegido]}
>
  <ListaBicicletas bicicletas={bicicletasVisibles} estaciones={estaciones} />
</ErrorBoundary>

Qué aporta sobre la implementación propia:

Prestación Para qué sirve
FallbackComponent Un componente completo como alternativa, con error y resetErrorBoundary inyectados
onError El punto de registro, sin escribir componentDidCatch
onReset Se ejecuta al reintentar: el sitio donde recargar datos o limpiar estado
resetKeys Reinicia el límite automáticamente cuando cambia alguno de esos valores
useErrorBoundary Un hook para enviar a mano un error asíncrono al límite más cercano

resetKeys resuelve un caso real: si el catálogo falla con el filtro «De carga» y la persona cambia a «Urbanas», tiene sentido reintentar solo por ese cambio, sin pulsar ningún botón.

Recomendación práctica: escribe la clase a mano una vez para entender el mecanismo —es lo que acabas de hacer— y usa react-error-boundary en proyectos reales.

  1. Registrar el error en un servicio de monitorización

Un límite que solo pinta un mensaje bonito deja a tu equipo ciego: los fallos de producción ocurren en navegadores que no tienes delante. Por eso componentDidCatch es tan importante.

// src/utilidades/monitorizacion.js

/**
 * Envía un error al servicio de monitorización.
 * Genérico a propósito: sustituye la URL por la de tu proveedor.
 */
export function registrarError(error, componentStack, contexto = {}) {
  if (import.meta.env.DEV) {
    console.error('[monitorizacion] (desarrollo, no se envía)', error);
    return;
  }

  const informe = {
    mensaje: error.message,
    pila: error.stack,
    componentes: componentStack,
    url: window.location.href,
    navegador: navigator.userAgent,
    momento: new Date().toISOString(),
    version: import.meta.env.VITE_VERSION_APP ?? 'desconocida',
    ...contexto
  };

  // `keepalive` permite que el envío sobreviva a un cierre de pestaña
  fetch('/api/errores', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(informe),
    keepalive: true
  }).catch(() => {
    // Si falla el registro, no hacemos nada más: nunca provoques un segundo error
  });
}
// Conectado al límite
<LimiteDeError
  titulo="No hemos podido cargar el catálogo"
  alRegistrar={(error, componentStack) =>
    registrarError(error, componentStack, { seccion: 'catalogo', tipoElegido })
  }
>
  <ListaBicicletas … />
</LimiteDeError>

Reglas de oro del registro de errores:

  • No registres en desarrollo. Llenarías el panel de ruido con errores que estás provocando tú.
  • Añade contexto de negocio. Saber que el fallo fue en la sección «catálogo» con el filtro «carga» vale más que la pila de llamadas minificada.
  • Nunca envíes datos personales. El correo de usr-01, un token de sesión o el contenido de un formulario no deben salir en un informe de error. Envía identificadores, no datos.
  • El registro nunca debe lanzar. El .catch() vacío es intencionado: un error dentro del manejador de errores es la peor clase de error.
  • Sube los source maps. Sin ellos, la pila de producción es ilegible. Súbelos al servicio, no al servidor público.

En un proyecto real usarás un servicio comercial (hay varios muy conocidos) con su propio SDK, pero el patrón es idéntico: un punto único de registro llamado desde componentDidCatch o desde onError.

  1. Diseñar un buen mensaje de fallo

El límite funciona; ahora falta que el mensaje no arruine la experiencia. Un mensaje de fallo tiene cuatro trabajos: explicar qué ha pasado, delimitar el alcance, ofrecer una salida y no asustar.

Evita Prefiere
«Error: undefined is not a function» «No hemos podido mostrar el catálogo»
«Ha ocurrido un error inesperado» (y nada más) Explicar qué parte falla y qué sigue funcionando
Culpar a la persona («has hecho algo mal») Asumir el problema en primera persona del plural
Un callejón sin salida Un botón de reintento y una alternativa («consulta tus reservas»)
Un volcado técnico en pantalla Un identificador corto de incidencia para soporte
Emojis de alarma y colores estridentes Tono sobrio, con el color de error de tu sistema (--color-mantenimiento)

Un ejemplo aplicado a CicloUrbano:

function AlternativaCatalogo({ error, resetErrorBoundary, idIncidencia }) {
  return (
    <section className={estilos.fallo} role="alert">
      <h2>No hemos podido mostrar el catálogo</h2>

      <p>
        Ha habido un problema al cargar las bicicletas. El resto de CicloUrbano
        funciona con normalidad: puedes consultar tus reservas o buscar una estación.
      </p>

      <div className={estilos.acciones}>
        <button type="button" onClick={resetErrorBoundary}>Volver a intentarlo</button>
        <a href="/mis-reservas">Ir a mis reservas</a>
      </div>

      {idIncidencia && (
        <p className={estilos.incidencia}>
          Si el problema continúa, indica esta referencia a soporte:{' '}
          <code>{idIncidencia}</code>
        </p>
      )}
    </section>
  );
}

Cuatro detalles deliberados:

  • El título dice qué ha fallado, en lenguaje de la persona usuaria, no del programa.
  • La segunda frase delimita el daño. Saber que solo se ha caído una sección cambia por completo la percepción del problema.
  • Hay dos salidas: reintentar y seguir haciendo otra cosa. Un mensaje sin salida es un muro.
  • El identificador de incidencia conecta lo que ve la persona con lo que ve tu equipo en el panel de monitorización, sin mostrar detalles técnicos.

Y una nota de accesibilidad, cerrando el hilo de 03-06: el contenedor lleva role="alert" para que se anuncie al aparecer, y el mensaje no depende del color rojo para entenderse.

Errores Comunes y Consejos

  • Esperar que el límite capture un onClick. No lo hace, nunca. Los manejadores necesitan su propio try/catch y su propio estado de error.
  • Esperar que capture un fetch fallido. Tampoco: el código asíncrono queda fuera. Captura con try/catch y guarda el error en estado, o relánzalo durante el render si de verdad es fatal.
  • Poner efectos secundarios en getDerivedStateFromError. Se ejecuta durante el render y puede llamarse varias veces: los registros saldrían duplicados. Ese trabajo es de componentDidCatch.
  • Una alternativa que también puede fallar. Si tu mensaje lee error.response.data.mensaje y ese campo no existe, el límite lanza dentro de sí mismo y el fallo sube al límite superior. Alternativas simples y a prueba de datos ausentes.
  • Un solo límite global y nada más. Cumple con no dejar la pantalla en blanco, pero cualquier fallo se lleva por delante la aplicación entera. Añade límites por sección.
  • Un límite por componente. El extremo contrario: ruido, mensajes minúsculos por todas partes y fallos que pasan desapercibidos.
  • Probar solo en desarrollo. La superposición de Vite tapa el resultado. Comprueba siempre con npm run build && npm run preview.
  • Consejo: prueba tus límites a propósito. Añade temporalmente un componente que lance (function Bomba() { throw new Error('prueba'); }) dentro de cada sección protegida y confirma que el resto de la interfaz sobrevive.
  • Consejo: un límite no sustituye a la validación. Si sabes que precioHora puede faltar, escribe bicicleta.precioHora ?? 0 en la tarjeta. El límite es la red de seguridad para lo que no habías previsto, no una excusa para no comprobar datos.

Ejercicios

Ejercicio 1. Coloca los límites de error de CicloUrbano. Escribe el main.jsx con el límite global y el fragmento de App.jsx con tres límites granulares (catálogo, reservas y estaciones), cada uno con su título propio y su registro a registrarError con el contexto de la sección. Explica por qué Cabecera y PieDePagina se quedan fuera.

Ejercicio 2. Clasifica estos cinco fallos de CicloUrbano: ¿los captura un límite de error? Si no, indica cómo se maneja cada uno.

  1. TarjetaBicicleta lee bicicleta.precioHora.toFixed(2) y precioHora es undefined.
  2. manejarConfirmacion llama a un fetch que devuelve 500.
  3. Un setTimeout dentro de un efecto lanza un error a los dos segundos.
  4. El constructor de un componente de clase heredado lanza al recibir props inválidas.
  5. La alternativa de un límite intenta leer error.detalles.codigo y detalles no existe.

Ejercicio 3. Escribe AlternativaReservas, la interfaz de reserva para el límite del panel de reservas. Requisitos: reutilizar el componente Aviso de 04-02 con tono error; mostrar un título claro; explicar qué sigue funcionando; ofrecer un botón «Volver a intentarlo» y un enlace al catálogo; mostrar un identificador corto de incidencia generado con crypto.randomUUID().slice(0, 8); y ser a prueba de fallos (no debe lanzar aunque error sea null). Explica dónde debe generarse el identificador y por qué no puede generarse en el cuerpo del componente.

Soluciones

Solución 1.

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import LimiteDeError from './componentes/LimiteDeError.jsx';
import { registrarError } from './utilidades/monitorizacion.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <LimiteDeError
      titulo="CicloUrbano no está disponible ahora mismo"
      alRegistrar={(error, pila) => registrarError(error, pila, { seccion: 'global' })}
    >
      <App />
    </LimiteDeError>
  </StrictMode>
);
// src/App.jsx (fragmento del return)
<Diseno>
  <LimiteDeError
    titulo="No hemos podido cargar el catálogo"
    alRegistrar={(e, pila) => registrarError(e, pila, { seccion: 'catalogo', tipoElegido })}
  >
    <SelectorTipo tipoElegido={tipoElegido} alCambiarTipo={manejarCambioTipo} />
    <ListaBicicletas
      bicicletas={bicicletasVisibles}
      estaciones={estaciones}
      alSeleccionar={manejarSeleccionBicicleta}
      alReservar={manejarReserva}
    />
  </LimiteDeError>

  <LimiteDeError
    titulo="No hemos podido cargar tus reservas"
    alRegistrar={(e, pila) => registrarError(e, pila, { seccion: 'reservas' })}
  >
    <PanelReserva
      bicicleta={bicicletaSeleccionada}
      horas={horasReserva}
      alCambiarHoras={manejarCambioHoras}
      alConfirmar={manejarConfirmacion}
    />
  </LimiteDeError>

  <LimiteDeError
    titulo="No hemos podido cargar las estaciones"
    alRegistrar={(e, pila) => registrarError(e, pila, { seccion: 'estaciones' })}
  >
    <ListaEstaciones estaciones={estaciones} />
  </LimiteDeError>
</Diseno>

Cabecera y PieDePagina se quedan fuera por dos motivos. Primero, son marcado prácticamente estático: no consumen datos externos ni hacen cálculos sobre campos que puedan faltar, de modo que su probabilidad de fallo es mínima. Y segundo, si aun así fallaran, perder la navegación deja la aplicación inutilizable: ahí no interesa una alternativa local, sino que el fallo suba hasta el límite global y se muestre un mensaje de aplicación completa. Un límite se coloca donde tiene sentido seguir usando el resto.

Solución 2.

Caso ¿Lo captura? Manejo
1. precioHora indefinido en el render Es el caso canónico. Aun así, lo correcto es prevenirlo con bicicleta.precioHora ?? 0 y dejar el límite como red de seguridad
2. fetch con 500 en un manejador No Doble motivo: es un manejador y es asíncrono. try/catch con async/await y un estado errorReserva mostrado con Aviso
3. setTimeout que lanza a los dos segundos No El callback se ejecuta fuera del contexto de React. try/catch dentro del callback y guardar el error en estado
4. Constructor de una clase heredada Los constructores de los descendientes están cubiertos, igual que sus métodos de ciclo de vida
5. La alternativa lee un campo inexistente No, no lo captura ese límite El límite no se protege a sí mismo: el error sube al límite superior (el global). Solución: alternativa defensiva, error?.detalles?.codigo ?? 'sin código'

Solución 3.

// src/componentes/AlternativaReservas.jsx
import Aviso from './Aviso.jsx';

/**
 * Interfaz de reserva del límite de error del panel de reservas.
 * Props:
 *  - error        (objeto Error, opcional): puede llegar nulo
 *  - alReintentar (función, obligatorio)
 *  - idIncidencia (cadena, opcional)
 */
function AlternativaReservas({ error, alReintentar, idIncidencia }) {
  const detalle = error?.message ?? 'Error desconocido';

  return (
    <Aviso
      tono="error"
      titulo="No hemos podido cargar tus reservas"
      acciones={
        <>
          <button type="button" onClick={alReintentar}>Volver a intentarlo</button>
          <a href="/catalogo">Ver el catálogo</a>
        </>
      }
    >
      <p>
        Ha habido un problema con el panel de reservas. El catálogo de bicicletas y
        las estaciones siguen funcionando con normalidad.
      </p>
      {idIncidencia && (
        <p>
          Referencia para soporte: <code>{idIncidencia}</code>
        </p>
      )}
      <p className="detalle-tecnico">{detalle}</p>
    </Aviso>
  );
}

export default AlternativaReservas;

El identificador no puede generarse en el cuerpo del componente por dos razones. La primera es de corrección: crypto.randomUUID() en el cuerpo devolvería un valor distinto en cada render, así que la referencia que ve la persona usuaria cambiaría al repintarse el componente y ya no coincidiría con la que se envió al servicio de monitorización. La segunda es conceptual: generar un valor aleatorio durante el render lo hace impuro, y el render debe ser puro (04-03).

El sitio correcto es el momento en que se captura el error, es decir, dentro de componentDidCatch, que se ejecuta una sola vez por fallo y sí admite efectos secundarios:

componentDidCatch(error, infoDelError) {
  const idIncidencia = `inc-${crypto.randomUUID().slice(0, 8)}`;
  this.setState({ idIncidencia });
  if (this.props.alRegistrar) {
    this.props.alRegistrar(error, infoDelError.componentStack, idIncidencia);
  }
}

Así, el mismo identificador viaja al servicio de monitorización y aparece en pantalla: quien llame a soporte dará exactamente la referencia que el equipo puede buscar. Es el mismo criterio que sigue FormularioReserva en 03-05 al generar sus ids res-${crypto.randomUUID().slice(0, 8)} en el manejador de envío y no durante el render.

Conclusión

Un error lanzado durante el render desmonta el árbol entero de React y deja una pantalla en blanco. Los límites de error son la respuesta: componentes que capturan los fallos de su subárbol y pintan una interfaz alternativa. Se implementan con dos métodos que a día de hoy solo existen en componentes de clasestatic getDerivedStateFromError para decidir qué pintar y componentDidCatch para registrar—, y esa es la única excepción que queda a la regla de que todo se escribe con funciones y hooks. Has construido LimiteDeError para CicloUrbano con alternativa personalizable y botón de reintento, lo has colocado en dos niveles —uno global en main.jsx y varios granulares por sección— y sabes que no capturan manejadores de eventos, código asíncrono ni sus propios fallos, con la técnica adecuada para cada caso. Añade a eso el registro en un servicio de monitorización con contexto de negocio, react-error-boundary como opción práctica para el día a día, y unos mensajes de fallo que explican, delimitan y ofrecen una salida.

Con esto se cierra el Módulo 4. En cuatro lecciones el proyecto ha cambiado de escala: el estado subió al ancestro común y el catálogo filtra de verdad (04-01); la interfaz se recompuso con contenedores, huecos con nombre y especializaciones en lugar de herencia (04-02); aprendiste a leer el ciclo de vida clásico y a traducirlo (04-03); y montaste el mapa completo de los hooks con las reglas que los gobiernan (04-04).

Ahora toca recorrer ese mapa con detalle. El Módulo 5: Hooks de React dedica una lección a cada herramienta: useState a fondo con todos sus matices, useEffect y el modelo de sincronización que se anunció en 04-03, useRef para lo que se recuerda sin repintar, useContext para acabar con la perforación de props que quedó pendiente en 04-01, useReducer para el estado complejo y, finalmente, los hooks personalizados, donde tu propia lógica se convierte en una función reutilizable sin un solo envoltorio. La próxima lección es Hook useState.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados