User-Facing Error Copy
Actionable messages vs opaque stack traces on mobile.
Search across all documentation pages
Actionable messages vs opaque stack traces on mobile.
Quick-reference recipe card - copy-paste ready.
// src/errors/userMessages.ts
export type UserFacingError = {
title: string;
body: string;
action: "retry" | "settings" | "support" | "dismiss";
actionLabel: string;
};
const NETWORK: UserFacingError = {
title: "You're offline",
body: "Check your connection and try again. Your changes are saved on this device.",
action: "retry",
actionLabel: "Try again",
};
const UNKNOWN: UserFacingError = {
title: "Something went wrong",
body: "We could not complete that. Try again in a moment.",
action: "retry",
actionLabel: "Try again",
};
export function toUserFacingError(error: unknown): UserFacingError {
if (error instanceof TypeError && /network/i.test(error.message)) {
return NETWORK;
}
if (isHttpError(error, 401)) {
return {
title: "Session expired",
body: "Sign in again to continue.",
action: "dismiss",
actionLabel: "Sign in",
};
}
if (isHttpError(error, 503)) {
return {
title: "Service busy",
body: "Our servers are temporarily overloaded. Wait a few seconds and retry.",
action: "retry",
actionLabel: "Try again",
};
}
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 },
});When to reach for this:
error.message or a 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("User copy:", user);
console.groupEnd();
return;
}
// Production: Sentry/Crashlytics with scope + cause; never attach user.title to analytics PII
}
// 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="Loading profile" />;
}
if (userError) {
return (
<View style={styles.screen}>
<ErrorCallout
error={userError}
onAction={() => {
if (userError.action === "retry") void load();
// wire sign-in / support / settings per action enum
}}
/>
</View>
);
}
return (
<View style={styles.screen}>
<Text style={styles.title}>Hello, {name}</Text>
<Button title="Refresh" 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 - boundary copy matches the same tone
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);
// report to crash service in production
}
render() {
if (this.state.hasError) {
return (
<View style={boundaryStyles.wrap} accessibilityRole="alert">
<Text style={boundaryStyles.title}>This screen hit a snag</Text>
<Text style={boundaryStyles.body}>
The rest of the app still works. Try again or go back.
</Text>
<Button
title="Try again"
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 },
});What this demonstrates:
toUserFacingError maps messy internals to a fixed vocabulary of titles, bodies, and actions.reportError logs full detail in __DEV__ only - production users never see stack traces.ErrorCallout uses accessibilityRole="alert" and accessibilityLiveRegion for VoiceOver/TalkBack.toUserFacingError(cause) once; they never branch on error.message in JSX.title, body, action, actionLabel drive UI and analytics without leaking internals.cause; the UI receives only UserFacingError.| Part | Guideline | Example |
|---|---|---|
| Title | 3–6 words, no jargon | "You're offline" |
| Body | What happened + what is safe + hint | "Check your connection. Your draft is saved." |
| Action | One verb, matches recovery | "Try again" |
| Avoid | Status codes, exception names, URLs | Not "HTTP 503" or "TypeError: undefined" |
| Category | User title | Action |
|---|---|---|
| Offline / timeout | You're offline | Retry |
| Auth expired | Session expired | Sign in |
| Server 5xx | Service busy | Retry |
| Permission denied | Permission needed | Open settings |
| Validation | Check highlighted fields | Dismiss |
| Unknown | Something went wrong | Retry or support |
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>
);
}Render DevDiagnostics below ErrorCallout in development builds only. Strip it from release binaries - __DEV__ is false in production.
// Keep action handling exhaustive
function handleErrorAction(
user: UserFacingError,
handlers: Record<UserFacingError["action"], () => void>,
) {
handlers[user.action]();
}error.message from fetch - Often "Network request failed" or raw HTML. Fix: Map to UserFacingError; log raw body in reportError only.Alert gets user.title and user.body only.Button; support as tertiary text link.userError or an inline field error.accessibilityLiveRegion="assertive" on every keystroke validation interrupts screen readers. Fix: polite for form errors; assertive only for blocking screen-level failures.errors.offline.title) and resolve with i18n.t in the component.| Alternative | Use When | Don't Use When |
|---|---|---|
| Centralized mapper (this page) | Most app errors share a small vocabulary | Highly domain-specific errors needing unique UX per screen |
| Toast-only errors | Non-blocking background sync failures | Blocking errors - toasts disappear before users read them |
Raw API message field | Backend owns trusted, user-tested copy end-to-end | Third-party APIs or verbose developer messages |
| Error codes in UI ("E1042") | Support-heavy B2B with trained agents | Consumer apps - codes feel like blame |
| Redbox / LogBox in production | Never | Development only - redboxes are not user-facing copy |
No in production. In __DEV__, show stacks below the friendly callout for engineers. Support staff can look up incidents by timestamp/user ID in your dashboard - users do not need a fault code on screen.
One or two short sentences (roughly 120 characters). Mobile screens are narrow; long apologies push the action button below the fold.
Calm, direct, and helpful. Acknowledge the problem, state what is preserved ("your draft is saved"), and give one next step. Avoid humor for payment, health, or auth errors.
Boundaries catch render errors - copy should reassure that the rest of the app works and offer "Try again" or navigation back. Fetch errors are operation failures - copy can be specific (offline, session expired).
Let the user tap retry - auto-retry loops frustrate users on airplane mode and waste battery. Exception: background sync with exponential backoff, invisible to the user.
Inline under the field for validation ("Enter a valid email"). Screen-level ErrorCallout for submit failures (server rejected the form). Do not duplicate the same message in both places.
Store translation keys in the mapper, not literal strings, before the app ships multiple locales. Keep English strings as defaults for early-stage apps.
Reserve support for UNKNOWN or repeated failures after retry. Offline and session-expired errors are user-fixable - support links add noise.
Network failure UX covers banners, queues, and stale-while-revalidate. User-facing copy is the wording layer inside those patterns - same tone whether in a banner or a full-screen error.
Only if you control the API and test the strings. Wrap even trusted messages in a whitelist map - unexpected deploys can leak SQL fragments or internal service names.
accessibilityRole="alert", accessibilityLiveRegion="polite" (or assertive for critical blockers), and a visible focus order that lands on the action button after announcement.
ErrorUtils.setGlobalHandler catches unhandled native/JS fatals - pair with a generic "Something went wrong" screen and restart prompt. See Global Error Handlers for wiring; this page owns the copy.
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026