Mensaje de Error para el Usuario
Mensajes accionables en lugar de stack traces opacos en móvil.
Busca en todas las páginas de la documentación
Mensajes accionables en lugar de stack traces opacos en móvil.
Tarjeta de referencia rápida - lista para copiar y pegar.
// src/errors/userMessages.ts
export type UserFacingError = {
title: string;
body: string;
action: "retry" | "settings" | "support" | "dismiss";
actionLabel: string;
};
const NETWORK: UserFacingError = {
title: "Estás sin conexión",
body: "Verifica tu conexión e intenta de nuevo. Tus cambios están guardados en este dispositivo.",
action: "retry",
actionLabel: "Intentar de nuevo",
};
const UNKNOWN: UserFacingError = {
title: "Algo salió mal",
body: "No pudimos completar eso. Intenta de nuevo en un momento.",
action: "retry",
actionLabel: "Intentar de nuevo",
};
export function toUserFacingError(error: unknown): UserFacingError {
if (error instanceof TypeError && /network/i.test(error.message)) {
return NETWORK;
}
if (isHttpError(error, 401)) {
return {
title: "Sesión expirada",
body: "Inicia sesión de nuevo para continuar.",
action: "dismiss",
actionLabel: "Iniciar sesión",
};
}
if (isHttpError(error, 503)) {
return {
title: "Servicio ocupado",
body: "Nuestros servidores están temporalmente sobrecargados. Espera unos segundos e intenta de nuevo.",
action: "retry",
actionLabel: "Intentar de nuevo",
};
}
return UNKNOWN;
}
function isHttpError(error: unknown, status: number): boolean {
return (
typeof error === "object" &&
error !== null &&
"status" in error &&
(error as { status: number }).status === status
);
}// src/components/ErrorCallout.tsx
import { Button, StyleSheet, Text, View } from "react-native";
import type { UserFacingError } from "@/errors/userMessages";
type Props = {
error: UserFacingError;
onAction: () => void;
};
export function ErrorCallout({ error, onAction }: Props) {
return (
<View
style={styles.box}
accessibilityRole="alert"
accessibilityLiveRegion="polite"
>
<Text style={styles.title}>{error.title}</Text>
<Text style={styles.body}>{error.body}</Text>
<Button title={error.actionLabel} onPress={onAction} />
</View>
);
}
const styles = StyleSheet.create({
box: {
padding: 16,
borderRadius: 8,
backgroundColor: "#fef2f2",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "#fecaca",
gap: 8,
},
title: { fontSize: 16, fontWeight: "700", color: "#991b1b" },
body: { color: "#7f1d1d", lineHeight: 20 },
});Cuándo usarlo:
error.message o una redbox.// src/errors/reportError.ts
import type { UserFacingError } from "./userMessages";
export function reportError(
scope: string,
cause: unknown,
user: UserFacingError,
): void {
if (__DEV__) {
console.group(`[${scope}]`);
console.error(cause);
console.log("Copia de usuario:", user);
console.groupEnd();
return;
}
// Producción: Sentry/Crashlytics con scope + cause; nunca adjuntes user.title a PII de analytics
}
// src/screens/ProfileScreen.tsx
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Button,
StyleSheet,
Text,
View,
} from "react-native";
import { ErrorCallout } from "@/components/ErrorCallout";
import { reportError } from "@/errors/reportError";
import { toUserFacingError } from "@/errors/userMessages";
async function fetchProfile(): Promise<{ name: string }> {
const response = await fetch("https://api.example.com/me");
if (!response.ok) {
throw { status: response.status, message: await response.text() };
}
return (await response.json()) as { name: string };
}
export function ProfileScreen() {
const [name, setName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [userError, setUserError] = useState<ReturnType<
typeof toUserFacingError
> | null>(null);
const load = useCallback(async () => {
setLoading(true);
setUserError(null);
try {
const data = await fetchProfile();
setName(data.name);
} catch (cause) {
const user = toUserFacingError(cause);
setUserError(user);
reportError("ProfileScreen.load", cause, user);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
if (loading) {
return <ActivityIndicator accessibilityLabel="Cargando perfil" />;
}
if (userError) {
return (
<View style={styles.screen}>
<ErrorCallout
error={userError}
onAction={() => {
if (userError.action === "retry") void load();
// conecta inicio de sesión / soporte / configuración por acción enum
}}
/>
</View>
);
}
return (
<View style={styles.screen}>
<Text style={styles.title}>¡Hola, {name}</Text>
<Button title="Actualizar" onPress={() => void load()} />
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, justifyContent: "center", gap: 12 },
title: { fontSize: 22, fontWeight: "700" },
});
// src/components/ScreenErrorBoundary.tsx - la copia del boundary coincide con el mismo tono
import { Component, type ReactNode } from "react";
import { Button, StyleSheet, Text, View } from "react-native";
type Props = { children: ReactNode };
type State = { hasError: boolean };
export class ScreenErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error) {
if (__DEV__) console.error(error);
// reporta al servicio de crash en producción
}
render() {
if (this.state.hasError) {
return (
<View style={boundaryStyles.wrap} accessibilityRole="alert">
<Text style={boundaryStyles.title}>Esta pantalla tuvo un inconveniente</Text>
<Text style={boundaryStyles.body}>
El resto de la app sigue funcionando. Intenta de nuevo o regresa.
</Text>
<Button
title="Intentar de nuevo"
onPress={() => this.setState({ hasError: false })}
/>
</View>
);
}
return this.props.children;
}
}
const boundaryStyles = StyleSheet.create({
wrap: { flex: 1, justifyContent: "center", padding: 24, gap: 12 },
title: { fontSize: 18, fontWeight: "700" },
body: { color: "#64748b", lineHeight: 22 },
});Lo que esto demuestra:
toUserFacingError mapea internals desordenados a un vocabulario fijo de títulos, cuerpos y acciones.reportError registra detalle completo en __DEV__ solo - usuarios de producción nunca ven stack traces.ErrorCallout usa accessibilityRole="alert" y accessibilityLiveRegion para VoiceOver/TalkBack.toUserFacingError(cause) una vez; nunca ramifican en error.message en JSX.title, body, action, actionLabel conducen la UI y analytics sin filtrar internals.cause raw; la UI recibe solo UserFacingError.| Parte | Pauta | Ejemplo |
|---|---|---|
| Título | 3-6 palabras, sin jerga | "Estás sin conexión" |
| Cuerpo | Qué pasó + qué es seguro + pista | "Verifica tu conexión. Tu borrador está guardado." |
| Acción | Un verbo, coincide con recuperación | "Intentar de nuevo" |
| Evitar | Códigos de estado, nombres de excepción, URLs | No "HTTP 503" o "TypeError: undefined" |
| Categoría | Título de usuario | Acción |
|---|---|---|
| Sin conexión / timeout | Estás sin conexión | Reintentar |
| Auth expirado | Sesión expirada | Iniciar sesión |
| Servidor 5xx | Servicio ocupado | Reintentar |
| Permiso denegado | Permiso necesario | Abrir configuración |
| Validación | Verifica los campos resaltados | Descartar |
| Desconocido | Algo salió mal | Reintentar o soporte |
export function DevDiagnostics({ error }: { error: unknown }) {
if (!__DEV__) return null;
return (
<Text style={{ fontFamily: "monospace", fontSize: 11 }}>
{error instanceof Error ? error.stack : String(error)}
</Text>
);
}Renderiza DevDiagnostics debajo de ErrorCallout solo en construcciones de desarrollo. Elimínalo de binarios de lanzamiento - __DEV__ es false en producción.
// Mantén el manejo de acción exhaustivo
function handleErrorAction(
user: UserFacingError,
handlers: Record<UserFacingError["action"], () => void>,
) {
handlers[user.action]();
}error.message desde fetch - A menudo "Network request failed" o HTML raw. Solución: Mapea a UserFacingError; registra cuerpo raw en reportError solo.Alert obtiene solo user.title y user.body.Button principal; soporte como enlace de texto terciario.userError o un error de campo inline.accessibilityLiveRegion="assertive" en cada keystroke de validación interrumpe lectores de pantalla. Solución: polite para errores de formulario; assertive solo para fallos bloqueantes a nivel de pantalla.errors.offline.title) y resuelve con i18n.t en el componente.| Alternativa | Úsalo Cuando | No lo uses Cuando |
|---|---|---|
| Mapper centralizado (esta página) | La mayoría de errores de app comparten un vocabulario pequeño | Errores altamente específicos del dominio necesitando UX única por pantalla |
| Errores solo de toast | Fallos de sincronización de fondo no bloqueantes | Errores bloqueantes - los toasts desaparecen antes de que los usuarios los lean |
Campo message de API raw | El backend posee copia confiable y testeada de usuario end-to-end | APIs de terceros o mensajes de desarrollador verbose |
| Códigos de error en UI ("E1042") | B2B pesado en soporte con agentes entrenados | Apps de consumidor - los códigos se sienten como culpa |
| Redbox / LogBox en producción | Nunca | Solo desarrollo - las redboxes no son copia de usuario |
No en producción. En __DEV__, muestra stacks debajo del callout amable para ingenieros. El personal de soporte puede buscar incidentes por timestamp/ID de usuario en tu dashboard - los usuarios no necesitan un código de falta en pantalla.
Una o dos oraciones cortas (aproximadamente 120 caracteres). Las pantallas móviles son estrechas; las disculpas largas empujan el botón de acción debajo del fold.
Tranquilo, directo y útil. Reconoce el problema, establece qué se preserva ("tu borrador está guardado"), y da un siguiente paso. Evita humor para pagos, salud o errores de auth.
Los boundaries capturan errores de render - la copia debería asegurar que el resto de la app funciona y ofrecer "Intentar de nuevo" o navegación atrás. Los errores de fetch son fallos de operación - la copia puede ser específica (sin conexión, sesión expirada).
Deja que el usuario pulse reintentar - los loops de auto-retry frustran a usuarios en modo avión y desperdician batería. Excepción: sincronización de fondo con backoff exponencial, invisible para el usuario.
Inline bajo el campo para validación ("Ingresa un correo válido"). ErrorCallout a nivel de pantalla para fallos de envío (servidor rechazó el formulario). No dupliques el mismo mensaje en ambos lugares.
Almacena claves de traducción en el mapper, no strings literales, antes de que la app envíe múltiples locales. Mantén strings en inglés como defaults para apps en etapa temprana.
Reserva soporte para UNKNOWN o fallos repetidos después de reintentar. Los errores sin conexión y de sesión expirada son solucionables por el usuario - los enlaces de soporte añaden ruido.
UX de fallo de red cubre banners, colas y stale-while-revalidate. La copia de usuario es la capa de palabras dentro de esos patrones - mismo tono si está en un banner o un error de pantalla completa.
Solo si controlas la API y pruebas los strings. Envuelve incluso mensajes confiables en un mapa de whitelist - los despliegues inesperados pueden filtrar fragmentos SQL o nombres de servicio internos.
accessibilityRole="alert", accessibilityLiveRegion="polite" (o assertive para bloqueadores críticos), y un orden de foco visible que aterrice en el botón de acción después del anuncio.
ErrorUtils.setGlobalHandler captura fatales nativos/JS no manejados - empareja con una pantalla genérica "Algo salió mal" y prompt de reinicio. Ver Global Error Handlers para conexión; esta página posee la copia.
Versiones de Stack: Esta página fue escrita para React 19.2.3, React Native 0.86.0, y Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última actualización: 16 jul 2026