import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
type Props = {
children: React.ReactNode;
fallback: (props: FallbackProps) => React.ReactNode;
onError?: (error: Error, info: React.ErrorInfo) => void;
resetKeys?: ReadonlyArray<unknown>;
};
type State = {
error: Error | null;
};
function keysChanged(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>) {
if (a.length !== b.length) return true;
return a.some((value, i) => !Object.is(value, b[i]));
}
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.props.onError?.(error, info);
}
componentDidUpdate(prev: Props) {
if (!this.state.error) return;
const current = this.props.resetKeys ?? [];
const previous = prev.resetKeys ?? [];
if (keysChanged(previous, current)) {
this.reset();
}
}
reset = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
return this.props.fallback({ error: this.state.error, reset: this.reset });
}
return this.props.children;
}
}
import { useCallback, useRef } from "react";
import * as Sentry from "@sentry/react";
type ReportOptions = {
context?: string;
extra?: Record<string, unknown>;
};
const DEDUPE_WINDOW_MS = 5000;
export function useErrorReporter(release: string) {
const recent = useRef(new Map<string, number>());
const reportError = useCallback(
(error: Error, options: ReportOptions = {}) => {
const now = Date.now();
const seenAt = recent.current.get(error.message);
if (seenAt && now - seenAt < DEDUPE_WINDOW_MS) return;
recent.current.set(error.message, now);
Sentry.withScope((scope) => {
scope.setTag("release", release);
if (options.context) scope.setTag("context", options.context);
if (options.extra) scope.setExtras(options.extra);
Sentry.captureException(error);
});
if (process.env.NODE_ENV !== "production") {
console.error(`[${options.context ?? "app"}]`, error);
}
},
[release]
);
return { reportError };
}
import React from "react";
import { useLocation } from "react-router-dom";
import { ErrorBoundary } from "./ErrorBoundary";
import { useErrorReporter } from "./useErrorReporter";
import { RouterOutlet } from "./RouterOutlet";
export function App() {
const location = useLocation();
const { reportError } = useErrorReporter(process.env.APP_VERSION ?? "dev");
return (
<ErrorBoundary
resetKeys={[location.pathname]}
onError={(error, info) =>
reportError(error, {
context: "render",
extra: { componentStack: info.componentStack },
})
}
fallback={({ error, reset }) => (
<div role="alert" className="screen-error">
<h2>Something broke on this page</h2>
<p>{error.message}</p>
<button type="button" onClick={reset}>
Try again
</button>
</div>
)}
>
<RouterOutlet />
</ErrorBoundary>
);
}
React function components cannot catch render errors on their own; the only supported catch mechanism is a class component that implements getDerivedStateFromError and componentDidCatch. This snippet packages that requirement into a small, reusable boundary plus a reporting hook so that a thrown render error becomes a controlled fallback UI instead of a blank white screen.
In ErrorBoundary.tsx, the class holds error in state. getDerivedStateFromError runs during the render phase and must stay pure, so it only maps the thrown value into new state to swap in the fallback. componentDidCatch runs in the commit phase where side effects are allowed, which is where the onError callback fires with the React errorInfo (including the component stack). The resetKeys prop drives automatic recovery: componentDidUpdate diffuses a stale error whenever any key changes, which is the idiomatic way to clear a boundary after, say, a route or user id changes. The fallback render prop receives both the error and a reset function so callers control the recovery UI.
Separating reporting from rendering matters because the boundary should not hard-depend on a specific telemetry SDK. useErrorReporter.ts wraps that concern in a hook returning a stable reportError via useCallback, tagging every event with release and context scope so errors are searchable in Sentry. It also dedupes identical messages within a short window using a useRef cache, preventing a render loop from flooding the transport with thousands of duplicate events — a common and expensive pitfall.
App.tsx wires them together. The reporter is created once and passed as onError, and resetKeys={[location.pathname]} makes navigation clear a previously failed screen automatically. The fallback shows a friendly message with a manual retry that calls reset, forcing the subtree to re-mount from a clean state.
The main trade-off is granularity: one boundary at the root protects everything but loses the whole page, so boundaries are usually placed around independent regions. Note also that these boundaries do not catch errors in event handlers, async callbacks, or SSR — those still need explicit try/catch and manual reportError calls.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
Share this code
Here's the card — post it anywhere.