Error boundaries have been a safety net ever since they landed in component-based frameworks like React. They sit around parts of your tree, catch errors thrown during render, and step in with logging or a fallback UI, so the whole app doesn’t blow up.
But the cracks show fast once you look at how modern apps behave. With so much async work and UI reacting to everything, error boundaries feel more fragile than protective. They look solid on the surface, but in practice, they only catch one narrow class of failures: render-time exceptions. They’re tied to the component render lifecycle, which is a synchronous moment, so anything that happens outside that moment – async calls, event handlers, background operations – slips straight past them. It’s like having a fire alarm that only goes off once the entire place is already burning, not when the spark first hits.
This isn’t sustainable as apps get more reactive and more distributed. That’s where signals come in. Signals give you a precise and consistent way to handle errors, no matter when or where they happen. They’re not bound to the component tree, and they don’t rely on the render cycle. Instead, they treat errors as a reactive state that flows through your application.
In this article, we’ll explore where error boundaries fall short, why signals offer a better model, and how this shift leads to cleaner, more resilient error handling across your entire app.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
Error boundaries were originally designed to prevent a single rendering error from taking down the entire VDOM tree. That approach held up back when apps were mostly synchronous, but modern UI patterns lean heavily on async work, background updates, and reactive data. That shift exposes the weaknesses in this model. Before we get into how signals address these gaps, let’s look at the key places where error boundaries consistently fall short.
setTimeout, requestAnimationFrame, or API calls (e.g., fetch, axios) are not caught by error boundariesonClick, onChange) are not caught by error boundaries. Developers have to resort to writing try...catch blocks inside event handler functions<ErrorBoundary fallback={<ErrorScreen />}>
<Dashboard>
<UserProfile /> {/* No Errors */}
<DataVisualization /> {/* No Errors */}
<NotificationFeed /> {/* Throws error → EVERYTHING LOST */}
</Dashboard>
</ErrorBoundary>
You could wrap each component in its own error boundary, but that gets messy fast and exposes the real weakness here: error boundaries follow the component structure, not the actual flow of data through your app.
To be fair, error boundaries still have one real use: catching catastrophic render failures at the very top of your app. But outside of that narrow case, they struggle to handle the kinds of errors modern apps produce.
Signals are reactive primitives that hold values that change over time. They let the UI update in a fine-grained way, which usually means better performance. And unlike component state, signals live outside the component hierarchy – they only notify the parts of the UI that actually depend on them.
So how does this tie into handling errors?
Signals break the link between data flow and the component render tree. That separation gives you a much more flexible and precise way to react to errors wherever they occur. Let’s walk through a few examples to see how this works in practice.
To use signals this way, you have to rethink how you treat errors. We usually think of errors as events that fire and then bubble up the component tree until something catches them. With signals, it’s different – you treat errors as a reactive state that moves through your application like any other piece of data. Here’s a simple example:
function PaymentProcessor() {
const [paymentStatus, setPaymentStatus] = createSignal('idle');
const [error, setError] = createSignal(null);
const processPayment = async (paymentData) => {
setPaymentStatus('processing');
setError(null); // Clear previous errors
try {
const result = await api.processPayment(paymentData);
setPaymentStatus('success');
} catch (err) {
setError(err); // Error is now a reactive state
setPaymentStatus('error');
}
};
// UI reacts to error state automatically
return (
<div>
<Show when={error()}>
<div class="error-message">
Payment failed: {error().message}
<button onClick={retryPayment}>Retry</button>
</div>
</Show>
</div>
);
}
The example above shows a typical async operation. Instead of letting the error explode up the component tree, we tie it to a signal. When the async call fails, we update that signal with setError, and the UI reacts on its own. This lets us handle the failure cleanly and locally, without the full-page meltdown you get with error boundaries.
One of the biggest wins with signals is the consistency. No matter where the error comes from, the handling pattern stays the same. That’s a sharp contrast to error boundaries, which only work in very specific parts of your app. With signals, it doesn’t matter if the failure happens in sync code, async code, or an event handler – you deal with it the same way every time:
// Event handlers - same pattern
const handleUserAction = () => {
try {
performCriticalOperation();
} catch (err) {
errorSignal.set(err); // Consistent handling
}
};
// Async operations - same pattern
const fetchData = async () => {
try {
const data = await api.getData();
} catch (err) {
errorSignal.set(err); // Consistent handling
}
};
// Effects and computations - same pattern
createEffect(() => {
try {
document.title = computedTitle();
} catch (err) {
errorSignal.set(err); // Consistent handling
}
};
This consistency removes the guesswork. You don’t have to remember which parts of your app are covered by error boundaries and which still need manual try–catch blocks. No matter where the error comes from, it moves through the same reactive error state, and the UI responds automatically.
Consistency is great, but the real power shows up when you start isolating errors without blowing up user state.
Signals enable granular error containment that preserves user context and application state. Where error boundaries would replace entire component trees, signals allow precise error isolation:
function Dashboard() {
const userError = createSignal(null);
const ordersError = createSignal(null);
const analyticsError = createSignal(null);
return (
<div class="dashboard">
{/* Each section maintains independent error state */}
<div class="panel">
<Show when={userError()}>
<ErrorCard message="User data issue" />
</Show>
<UserProfile onError={userError.set} />
</div>
<div class="panel">
<Show when={ordersError()}>
<ErrorCard message="Orders loading failed" />
</Show>
<OrdersPanel onError={ordersError.set} />
</div>
<div class="panel">
<Show when={analyticsError()}>
<ErrorCard message="Analytics unavailable" />
</Show>
<AnalyticsDashboard onError={analyticsError.set} />
</div>
</div>
);
}
In the code above, you can notice that a failure in the analytics panel doesn’t affect the user profile or orders sections. Users retain scroll positions, form inputs, and UI state – something impossible with error boundaries’ all-or-nothing approach.
Signals naturally facilitate error communication across component boundaries without prop drilling or context overhead required in hook-based solutions:
// Global error manager using signals
const errorManager = {
api: createSignal(null),
auth: createSignal(null),
network: createSignal(null)
};
// Any component can report errors
function PaymentForm() {
const processPayment = async () => {
try {
await submitPayment();
} catch (err) {
errorManager.api.set(err); // Global error state update
}
};
}
// Any component can react to errors
function NotificationPanel() {
return (
<Show when={errorManager.api()}>
<Alert message="API Error: Please check your connection" />
</Show>
);
}
This cross-component coordination happens automatically through the reactive graph, ensuring that error states propagate instantly to all dependent components without manual subscription management.
Looking at these examples, it’s easy to see why signals might feel similar to Hooks. It even seems like you could drop the same pattern into a React or Next app without much trouble. And technically, you can get part of the way there using Hooks alongside error boundaries – but the same old limitations will still show up. Signals avoid those issues by design. Here’s what that looks like in practice:
import React, { useState, useEffect } from 'react';
function ProblemComponent() {
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const [shouldThrow, setShouldThrow] = useState(false);
const handlePayment = () => {
throw new Error('Payment failed in event handler');
};
useEffect(() => {
fetch('/api')
.then(response => {
if (!response.ok) throw new Error('API error');
return response.json();
})
.then(data => setData(data))
.catch(err => {
setError(err.message); // Local state update only
});
}, []);
// The ONLY thing Error Boundaries can catch
if (shouldThrow) {
throw new Error('Render error - this WILL be caught by Error Boundary');
}
return (
<div>
{error && <div>Local error: {error}</div>}
{data && <div>Data: {JSON.stringify(data)}</div>}
<button onClick={handlePayment}>
Pay (throws event error - not caught)
</button>
<button onClick={() => setShouldThrow(true)}>
Cause Render Error (caught by Error Boundary)
</button>
</div>
);
}
// using with Error Boundary
function App() {
return (
<ErrorBoundary fallback={<div>Error Boundary caught something!</div>}>
<ProblemComponent />
</ErrorBoundary>
);
}
If you look at the example above, it mirrors the earlier signal pattern pretty closely, but all the same issues show up again. That’s because error boundaries only operate at the React reconciliation layer, while most real errors happen at the plain JavaScript execution layer. Hooks can’t bridge that gap, and no amount of state juggling will change that limitation.
Shifting from error boundaries to signal-based error handling isn’t just a technical upgrade – it’s an architectural correction. Error boundaries fall short because they treat failures as rare events tied to the component tree. Signals work because they treat errors as normal pieces of state inside the data flow.
With this model, you never need to wonder “Will the boundary catch this?” With signals, the answer is always yes. Render errors, async failures, event handler exceptions, third-party issues – it all follows the same pattern: update a reactive error state and let the UI react on its own. The result is an application that recovers cleanly, stays predictable, and doesn’t punish the user for a small, isolated failure.
In practice, this means fewer full-page crashes, fewer confused users, and a UI that stays steady even when the underlying data isn’t.
Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not
server-side
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
// Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>

Build fast, scalable UIs with TanStack Virtual: virtualize long lists, support dynamic row heights, and implement infinite scrolling with React.

CI/CD isn’t optional anymore. Discover how automated builds and deployments prevent costly mistakes, speed up releases, and keep your software stable.

A quick comparison of five AI code review tools tested on the same codebase to see which ones truly catch bugs and surface real issues.

corner-shapeLearn about CSS’s corner-shape property and how to use it, as well as the more advanced side of border-radius and why it’s crucial to using corner-shape effectively.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now