Post contents
Despite our best efforts, bugs will find their way into our applications. Unfortunately, we can't simply ignore them, or the user experience suffers greatly.
Take the following code:
- React
- Angular
- Vue
const App = () => { const items = [ { id: 1, name: "Take out the trash", priority: 1 }, { id: 2, name: "Cook dinner", priority: 1 }, { id: 3, name: "Play video games", priority: 2 }, ]; const priorityItems = items.filter((item) => item.item.priority === 1); return ( <> <h1>To-do items</h1> <ul> {priorityItems.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> </> );};
Without running the code, everything looks pretty good, right?
Maybe you've spotted the error by now — that's great! Remember that we all make these small mistakes from time-to-time. Don't dismiss the idea of error handling out of hand as we go forward.
But oh no! When you run the application, it's not showing the h1
or any of the list items like we would expect it to.
The reason those items aren't showing on-screen is that an error is being thrown. Open your console on any of these examples, and you'll find an error waiting for you:
Error: can't access property "priority", item.item is undefined
Luckily, this error is a fairly easy fix, but even if we do, bugs will inevitably be introduced into our apps. A white screen is a pretty subpar experience for our end users — they likely won't even understand what happened that led them to this broken page.
While I doubt we'll ever convince our users that an error is a good thing, how can we make this user experience better, at least?
Before we do that, however, let's explore why throwing an error causes the rendering of a page to fail.
Throwing Errors Causes Blank Screens?!
As shown before, when an error is thrown during a component's render step, it will fail to render any of the contents from the component's template. This means that the following will throw an error and prevent rendering from occurring:
- React
- Angular
- Vue
const ErrorThrowingComponent = () => { throw new Error("Error"); return <p>Hello, world!</p>;};
However, if we change our code to throw an error during an event handler, the contents will render fine but fail to execute the logic of said error handler:
- React
- Angular
- Vue
const EventErrorThrowingComponent = () => { const onClick = () => { throw new Error("Error"); }; return <button onClick={onClick}>Click me</button>;};
This behavior may seem strange until you consider how JavaScript's throw
clause works. When a JavaScript function throws an error, it also acts as an early return of sorts.
function getRandomNumber() { // Try commenting this line and seeing the different behavior throw new Error("There was an error"); // Anything below the "throw" clause will not run console.log("Generating a random number"); // This means that values returned after a thrown error are not utilized return Math.floor(Math.random() * 10);}try { const val = getRandomNumber(); // This will never execute because the `throw` bypasses it console.log("I got the random number of:", val);} catch (e) { // This will always run instead console.log("There was an error:", e);}
Moreover, these errors exceed past their scope, meaning that they will bubble up the execution stack.
What does that mean in English?
In practical terms, this means that a thrown error will exceed the bounds of the function you called it in and make its way further up the list of functions you called to get to the thrown error.
function getBaseNumber() { // Error occurs here, throws it upwards throw new Error("There was an error"); return 10;}function getRandomNumber() { // Error occurs here, throws it upwards return Math.floor(Math.random() * getBaseNumber());}function getRandomTodoItem() { const items = [ "Go to the gym", "Play video games", "Work on book", "Program", ]; // Error occurs here, throws it upwards const randNum = getRandomNumber(); return items[randNum % items.length];}function getDaySchedule() { let schedule = []; for (let i = 0; i < 3; i++) { schedule.push( // First execution will throw this error upwards getRandomTodoItem(), ); } return schedule;}function main() { try { console.log(getDaySchedule()); } catch (e) { // Only now will the error be stopped console.log("An error occurred:", e); }}
Because of these two properties of errors, React, Angular, and Vue are unable to "recover" (continue rendering after an error has occurred) from an error thrown during a render cycle.
Errors Thrown in Event Handlers
Conversely, due to the nature of event handlers, these frameworks don't need to handle errors that occur during event handlers. Assume we have the following code in an HTML file:
<!-- index.html --><button id="btn">Click me</button><script> const el = document.getElementById("btn"); el.addEventListener("click", () => { throw new Error("There was an error"); });</script>
When you click on the <button>
here, it will throw an error, but this error will not escape out of the event listener's scope. This means that the following will not work:
try { const el = document.getElementById("btn"); el.addEventListener("click", () => { throw new Error("There was an error"); });} catch (error) { // This will not ever run with this code alert("We're catching an error in try/catch");}
So to catch an error in an event handler, React, Angular, or Vue would have to add a window 'error'
listener, like so:
const el = document.getElementById("btn");el.addEventListener("click", () => { throw new Error("There was an error");});window.addEventListener("error", (event) => { const error = event.error; alert("We're catching an error in another addEventListener");});
But let's think about what adding this window
listener would mean:
- More complex code in the respective frameworks
- Harder to maintain
- Larger bundle size
- When the user clicks on a faulty button, the whole component crashes rather than a single aspect of it failing
This doesn't seem worth the tradeoffs when we're able to add our own try/catch
handlers inside event handlers.
After all, a partially broken application is better than a fully broken one!
Errors Thrown in Other APIs
This property of an error being thrown in an error handler not preventing a render transfers to other aspects of these frameworks as well:
- React
- Angular
- Vue
While some other frameworks catch errors inside async APIs (like React's useEffect
), React will not recover from an error thrown in any of the built-in React Hooks covered so far:
const App = () => { // This will prevent rendering const stateVal = useState(() => { throw new Error("Error in state initialization function"); }); // This will also prevent rendering const memoVal = useMemo(() => { throw new Error("Error in memo"); }, []); // Will this prevent rendering? You bet! useEffect(() => { throw new Error("Error in useEffect"); }); // Oh, and this will too. useLayoutEffect(() => { throw new Error("Error in useEffect"); }); return <p>Hello, world!</p>;};
Now that we understand why these errors prevent you from rendering content, let's see how we're able to improve the user experience when errors do occur.
Logging Errors
The first step to providing a better end-user experience when it comes to errors is to reduce how many are made.
Well, duh
Sure, this seems obvious, but consider this: If an error occurs on the user's machine and it isn't caught during internally, how are you supposed to know how to fix it?
This is where the concept of "logging" comes into play. The general idea behind logging is that you can capture a collection of errors and information about the events that led up to the errors. You want to provide a way to export this data so that your user can send it to you to debug.
While this logging often involves submitting data to a server, let's keep things local to the user's machine for now.
- React
- Angular
- Vue
Up to this point, all of our React components have been functions. While this is how most modern React applications are built today, there is another way of writing a React component; this being the "class" API.
Class-based React components existed well before function components. Class-based components were in React since day one, and functional components were only truly made viable with a significant revamp in React 16.8, coinciding with the introduction of React Hooks.
Here's a simple React component in both functional and class-based APIs:
// Function componentconst FnCounter = (props) => { // Setting up state const [count, setCount] = useState(0); // Function to update state const addOne = () => setCount(count + 1); // Rendered UI via JSX return ( <div> <p>You have pushed the button {count} times</p> <button onClick={addOne}>Add one</button> {/* Using props to project children */} {props.children} </div> );};
// Class componentimport { Component } from "react";class ClassCounter extends Component { // Setting up state state = { count: 0 }; // Function to update state addOne() { // Notice we use an object and `setState` to update state this.setState({ count: this.state.count + 1 }); } // Rendered UI via JSX render() { return ( <div> <p>You have pushed the button {this.state.count} times</p> <button onClick={() => this.addOne()}>Add one</button> {/* Using props to project children */} {this.props.children} </div> ); }}
Both of these components work exactly the same, with no functional differences between them. This is because almost every API that was available to class components made its way over to functional components through React Hooks.
Almost every API made the migration to Hooks.
One of the few exceptions to that rule is the ability to catch and track errors that are thrown within a React application.
Use Class Components to Build an Error Boundary
Now that we understand what a class component is and why it's required to use one for error handling, let's build one ourselves!
Just like any other class component, we start with an extends
clause to tell React that this class is, in fact, a component.
From there, we add a special componentDidCatch
method, like so:
import { Component } from "react";class ErrorBoundary extends Component { componentDidCatch(error, errorInfo) { // Do something with the error console.log(error, errorInfo); } render() { return this.props.children; }}
This method is then called any time a child component throws an error.
Luckily for us, we can mix and match class components and function components. This means that we can demonstrate the componentDidCatch
handler using the following code:
const ErrorThrowingComponent = () => { // This is an example of an error being thrown throw new Error("Error");};const App = () => { return ( <ErrorBoundary> <ErrorThrowingComponent /> </ErrorBoundary> );};
Now, while our screen will still be white when the error is thrown, it will hit our componentDidCatch
handler as we would expect.
Great! We're now able to keep track of what errors are occurring in our app. Hopefully, this allows us to address bugs as the user experiences them, making the app feel more stable as time goes on.
Now, let's see if we're not able to make the experience a bit nicer for our users when they do hit an error.
Ignoring the Error
Some bugs? They're showstoppers. When they happen, you can't do anything to recover from the error, and as a result, you have to halt the user's ability to interact with the page.
Other bugs, on the other hand, may not require such harsh actions. For example, if you can silently log an error, pretending that nothing ever happened and allowing the app to continue on as normal, that oftentimes leads to a better user experience.
Let's see how we can implement this in our apps.
- React
- Angular
- Vue
Unfortunately, React is not able to handle thrown errors invisibly to the user when they're thrown within a functional component. As mentioned before, this is ultimately because React functional components are simply functions that do not have an escape mechanism built into them for errors being thrown.
Fallback UI
While silently failing can be a valid strategy to hide errors from your user, other times, you may want to display a different UI when an error is thrown.
For example, let's build a screen that tells the user that an unknown error has occurred when something is thrown.
- React
- Angular
- Vue
Because our ErrorBoundary
component renders the children that are passed in, we can update our state when an error occurs. To do this, React provides a special static handler method called getDerivedStateFromError
, which allows us to set a property in our state
object when an error is hit.
class ErrorBoundary extends Component { state = { hasError: false }; static getDerivedStateFromError(error) { return { hasError: true }; } componentDidCatch(error, errorInfo) { console.log(error, errorInfo); } render() { if (this.state.hasError) { return <h1>Something went wrong.</h1>; } return this.props.children; }}
Displaying the Error
While displaying a fallback UI is often to the user's benefit, most users want some indication of what went wrong rather than simply that "something" went wrong.
Let's display the error thrown by the component to our users.
- React
- Angular
- Vue
While we previously used getDerivedStateFromError
to set a Boolean in our state
object, we can instead use the first argument of the static handler to assign the object to an Error
value.
// JSON.stringify-ing an Error object provides `{}`.// This function fixes thatconst getErrorString = (err) => JSON.stringify(err, Object.getOwnPropertyNames(err));class ErrorBoundary extends Component { state = { error: null }; static getDerivedStateFromError(error) { return { error: error }; } componentDidCatch(error, errorInfo) { console.log(error, errorInfo); } render() { if (this.state.error) { return ( <div> <h1>You got an error:</h1> <pre style={{ whiteSpace: "pre-wrap" }}> <code>{getErrorString(this.state.error)}</code> </pre> </div> ); } return this.props.children; }}
Challenge
Let's say that we were building out our previous code challenge and accidentally typo-d the name of a variable in our Sidebar
component:
- React
- Angular
- Vue
const Sidebar = forwardRef(({ toggle }, ref) => { const [isCollapsed, setIsCollapsed] = useState(false); const setAndToggle = (v) => { setIsCollapsed(v); toggle(v); }; // ... const toggleCollapsed = () => { setAndToggle(isCollapsed); }; /** * `collapsed` doesn't exist! * It's supposed to be `isCollapsed`! 😱 */ if (collapsed) { return <button onClick={toggleCollapsed}>Toggle</button>; } return ( <div> <button onClick={toggleCollapsed}>Toggle</button> <ul style={{ padding: "1rem" }}> <li>List item 1</li> <li>List item 2</li> <li>List item 3</li> </ul> </div> );});
Upon rendering the sidebar, we're greeted with a JavaScript ReferenceError
:
"collapsed is not defined";
While we can solve this by correcting the typo, we'll also want to add an error handler to log these kinds of issues in case they happen in production. After all, if a bug is found in the wild without a way to report it back to the developer, is it ever fixed?
Let's solve this by:
- Figuring out how the user will report bugs
- Implementing an error handler
- Showing the user a nicer error screen
Reporting Bugs Back to Developers
Let's provide the user a means to email us if they find something similar in their time using the app.
We can do this by showing the user a mailto:
link when an error occurs. That way, reporting the bug is a single mouse click.
This mailto:
link might look like the following HTML
<a href="mailto:dev@example.com&subject=Bug%20Found&body=There%20was%20an%20error" >Email Us</a>
Where subject
and body
are encoded using encodeURIComponent
like so:
// JavaScript pseudo-codeconst mailTo = "dev@example.com";const errorMessage = `There was some error that occurred. It's unclear why that happened.`;const header = "Bug Found";const encodedErr = encodeURIComponent(errorMessage);const encodedHeader = encodeURIComponent(header);const href = `mailto:${mailTo}&subject=${encodedHeader}&body=${encodedErr}`;// HREF can be bound via each frameworks' attribute binding syntaxconst html = `<a href="${href}">Email Us</a>`;
Implementing the Error Handler
With a plan of attack outlined, let's take a step back and evaluate how we'll implement our error handler in the first place.
Remember: Implement first, increment second.
Let's start with an error handler that will catch our error and display a UI when it occurs.
We'll also make sure that this error handler is application-wide to ensure that it shows up when any error in the app comes up:
- React
- Angular
- Vue
class ErrorBoundary extends Component { state = { error: null }; static getDerivedStateFromError(error) { return { error: error }; } render() { const err = this.state.error; if (err) { return ( <div> <h1>There was an error</h1> <pre> <code>{err.message}</code> </pre> </div> ); } return this.props.children; }}const Root = () => { return ( <ErrorBoundary> <App /> </ErrorBoundary> );};
Showing a Nicer Error Message
Now that we have a method of showing the error to the user when it occurs, let's make sure that we can report the bug back to the development team. We'll do this by displaying all the information a user would need to report a bug alongside an auto-filled `mailto:`` link so that emailing the developer is a single-button press away.
- React
- Angular
- Vue
class ErrorBoundary extends Component { state = { error: null }; static getDerivedStateFromError(error) { return { error: error }; } render() { const err = this.state.error; if (err) { const mailTo = "dev@example.com"; const header = "Bug Found"; const message = ` There was a bug found of type: "${err.name}". The message was: "${err.message}". The stack trace is: """ ${err.stack} """ `.trim(); const encodedMsg = encodeURIComponent(message); const encodedHeader = encodeURIComponent(header); const href = `mailto:${mailTo}&subject=${encodedHeader}&body=${encodedMsg}`; return ( <div> <h1>{err.name}</h1> <pre> <code>{err.message}</code> </pre> <a href={href}>Email us to report the bug</a> <br /> <br /> <details> <summary>Error stack</summary> <pre> <code>{err.stack}</code> </pre> </details> </div> ); } return this.props.children; }}