Implement error boundaries in a product component
Use error boundaries to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
Error boundaries are the question that exposes whether you understand what React catches and what it does not. The interview-grade rule: error boundaries catch exceptions thrown during rendering, in lifecycle methods, and in constructors of components below them. They do NOT catch errors inside event handlers, async callbacks, server-side rendering, or the boundary itself.
A senior solution names three patterns. A class boundary with getDerivedStateFromError + componentDidCatch is the only way to declare a boundary in React 19; hooks cannot. A reset hook on the boundary lets the fallback offer a "Try again" action without a full page reload. A useErrorBoundary hook bridges async errors back into render so the boundary catches them. Each pattern handles a different failure surface.
Volunteer the failures. A boundary placed too high crashes the whole app on a single component error; placed too low, the fallback is invisible to users. A reset that does not change props or key may render the same broken state twice. An error reporter that depends on the boundary itself for context will silently lose data when the boundary itself throws. Logging from componentDidCatch to console without a reporter is fine for tests but invisible in production — wire a real onError.
Reference Implementation: Error Boundary With Reset And useErrorBoundary
ProductErrorBoundary owns the error state and exposes reset to the fallback. useErrorBoundary lets async children re-throw into the nearest boundary.
type ErrorBoundaryProps = {
// The fallback is a render prop so callers can include a Try Again action
// or use error metadata to decide what to show.
fallback: (state: { error: unknown; reset: () => void }) => React.ReactNode;
// Pluggable reporter so the same boundary works in production (Sentry,
// Datadog) and tests (in-memory spy).
onError?: (error: unknown, info: React.ErrorInfo) => void;
children: React.ReactNode;
};
type ErrorBoundaryState = { error: unknown };
export class ProductErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: unknown) {
return { error };
}
componentDidCatch(error: unknown, info: React.ErrorInfo) {
this.props.onError?.(error, info);
}
// Reset is exposed to the fallback so the user can recover without a
// full page reload. The boundary remounts its children, which re-runs
// any Suspense data fetches the children depend on.
reset = () => this.setState({ error: null });
render() {
if (this.state.error !== null) {
return this.props.fallback({ error: this.state.error, reset: this.reset });
}
return this.props.children;
}
}
// useErrorBoundary mirrors the class API for hook consumers. It throws
// inside the render phase so the nearest boundary catches it. Errors that
// arrive in event handlers or effects must be re-thrown here because React
// does not catch async errors automatically.
export function useErrorBoundary() {
const [error, setError] = React.useState<unknown>(null);
if (error !== null) throw error;
return React.useCallback((nextError: unknown) => setError(nextError), []);
}
type SettingsPanelProps = {
loadSettings: () => Promise<{ theme: 'light' | 'dark' }>;
};
export function SettingsPanel({ loadSettings }: SettingsPanelProps) {
const reportError = useErrorBoundary();
const [settings, setSettings] = React.useState<{ theme: 'light' | 'dark' } | null>(null);
React.useEffect(() => {
let cancelled = false;
loadSettings().then(
(next) => { if (!cancelled) setSettings(next); },
(cause) => { if (!cancelled) reportError(cause); }
);
return () => { cancelled = true; };
}, [loadSettings, reportError]);
if (!settings) return <p role="status">Loading settings...</p>;
return <p>Theme: {settings.theme}</p>;
}Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
function Boom(): React.ReactNode {
throw new Error('crash');
}
describe('ProductErrorBoundary', () => {
it('renders the fallback when a child throws', () => {
const fallback = ({ error }: { error: unknown }) => (
<p role="alert">{error instanceof Error ? error.message : 'Unknown'}</p>
);
render(
<ProductErrorBoundary fallback={fallback}>
<Boom />
</ProductErrorBoundary>
);
expect(screen.getByRole('alert')).toHaveTextContent('crash');
});
it('reset returns the children', async () => {
const user = userEvent.setup();
let throwOnce = true;
function MaybeThrow() {
if (throwOnce) throw new Error('once');
return <p>Recovered</p>;
}
const fallback = ({ reset }: { reset: () => void }) => (
<button
type="button"
onClick={()=> {
throwOnce= false;
reset();
}}
>
Retry
</button>
);
render(
<ProductErrorBoundary fallback={fallback}>
<MaybeThrow />
</ProductErrorBoundary>
);
await user.click(screen.getByRole('button', { name: /retry/i }));
expect(screen.getByText('Recovered')).toBeInTheDocument();
});
it('reports through onError', () => {
const onError = vi.fn();
const fallback = () => <p>err</p>;
render(
<ProductErrorBoundary fallback={fallback} onError={onError}>
<Boom />
</ProductErrorBoundary>
);
expect(onError).toHaveBeenCalledWith(expect.any(Error), expect.any(Object));
});
});Interviewer Signal
Shows whether you can explain React behavior while building maintainable product UI.
Constraints
- Name what is render-derived and what is stored state.
- Keep side effects owned by events or effects deliberately.
- Provide a testable boundary for business logic.
Model Answer Shape
- Start from the user workflow and state ownership.
- Move pure decisions out of JSX when the branch logic grows.
- Use React primitives to express ownership, not to hide unclear state.
Tradeoffs
- Colocating state improves clarity until sibling coordination becomes the real problem.
- Memoization helps only after render cost or identity churn is measured.
Edge Cases
- Strict Mode re-running development effects.
- Stale closures after async work resolves.
- Unmounts and route changes during in-flight operations.
Testing And Proof
- Reducer or pure function test for core state transitions.
- Interaction test for the user workflow.
- Regression case for stale or repeated async behavior.
Follow-Ups
- How would this change with server rendering?
- Where would you place this state in a larger app?