Building thought-out and creative UIs is difficult. Even excellent UX/UI designs can never tell the full story of a web app.
Because they are only a static representation of something inherently dynamic, it’s up to developers to make designs come to life, which, of course, means taking into account all possible states.
In this article, we’ll cover the best practices to use in client-side rendered React apps when handling loading, error, and empty state.
Imagine we want to recreate the table from Wikipedia’s list of cat breeds with React and Chakra UI in the context of the single page application (SPA) we are building.
The image below is the design we aim to achieve. Simple enough, right?
To begin with, let’s extract our cat breeds data into a separate file, data.json
:
[ { "name": "Abyssinian", "origin": "Unspecified, but somewhere in Afro-Asia likely Ethiopia", "type": "Natural", "bodyType": "Semi-foreign", "coat": "Short", "pattern": "Agouti", "imgUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9b/Gustav_chocolate.jpg/200px-Gustav_chocolate.jpg" }, { "name": "Aegean", "origin": "Greece", "type": "Natural", "bodyType": "Moderate", "coat": "Semi-long", "pattern": "Multi-color", "imgUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Aegean_cat.jpg/200px-Aegean_cat.jpg" }, ... ]
We can now create a TableComponent
that relies on this data:
import { Table, Thead, Tbody, Tr, Th, Td } from "@chakra-ui/react"; import data from "../public/data.json"; const TableComponent = () => { return ( <Table colorScheme="blue" overflow="none"> <Thead> <Tr> <Th></Th> <Th>Name</Th> <Th>Origin</Th> <Th>Type</Th> <Th>Body type</Th> <Th>Coat</Th> <Th>Pattern</Th> </Tr> </Thead> <Tbody> {data.map((cat) => ( <Tr key={cat.name}> <Td p="2"> <Box bgImage={cat.imgUrl} w="150px" h="150px" backgroundSize="cover" /> </Td> <Td>{cat.name}</Td> <Td>{cat.origin}</Td> <Td>{cat.type}</Td> <Td>{cat.bodyType}</Td> <Td>{cat.coat}</Td> <Td>{cat.pattern}</Td> </Tr> ))} </Tbody> </Table> ); };
Done! It looks exactly as expected.
But this implementation is also very naive! The data for our cats table is simply hard-coded. In a real-world React app, it will most likely come from a server. This, in turn, means that it will not be immediately available when the user opens the page. We have to wait for it.
Now we come to the first state that is not explicitly included in the image above, the loading state. When we request the data, we need to indicate to our users that something is loading while we wait for the server response. Otherwise, they will just see a blank screen.
So, let’s add a Spinner
to our TableComponent
:
import React, { useState } from "react"; import { ... Spinner } from "@chakra-ui/react"; import useCats from "./useCats"; const TableComponent = () => { // Custom data-fetching hook to handle the server request const { data, isLoading } = useCats(); if (isLoading) { return <Spinner />; } return ( <Table> ... </Table> ); };
This is starting to look much more realistic already. But here is a thought: do we need to show a spinner every time we are fetching the table data?
What if we want to navigate between pages, for example? It would be disruptive for the table to disappear and a spinner to appear every time the user goes to a new page. The same applies if we implement a search.
Or, what if we have already cached the table data in our app, but we still want to re-fetch it in the background to ensure it’s not stale? Once again, while we re-fetch, it will be much better for the user to be able to see the already available data instead of a spinner.
For our UI to truly feel polished, we need to indicate to users when something is happening in the background. It looks like we need a second loading state, one that allows us to also display our TableComponent
.
Let’s add it!
import React, { useState } from "react"; import { ... Spinner, Progress, } from "@chakra-ui/react"; import useCats from "./useCats"; const TableComponent = () => { const { data, isLoading } = useCats(); if (isLoading && !data) { return <Spinner />; } return ( <> {isLoading && ( <Progress /> )} <Table> ... </Table> </> ); };
This is neat! We now show our Spinner
only when there is no data to show the user. Otherwise, we display the much more subtle Progress
on top of the table.
But what if there is no data at all?
Because our data is coming from a server, we can’t know for sure if there will be data at all. We might receive an empty list. If this happens with our current implementation, the user will only see the Table
headers, which will be pretty confusing.
We should add an empty state with an appropriate message to our component to account for this scenario:
import React, { useState } from "react"; import { ... Spinner, Progress, Text } from "@chakra-ui/react"; import { SearchIcon } from "@chakra-ui/icons"; import useCats from "./useCats"; const EmptyState = () => { return ( <Box> <SearchIcon /> <Text> No cats found! </Text> </Box> ); }; const TableComponent = () => { const { data, isLoading } = useCats(); if (isLoading && !data) { return <Spinner />; } if (data?.length === 0) { return <EmptyState />; } return ( <> {isLoading && ( <Progress /> )} <Table> ... </Table> </> ); };
Perfect! We are almost done. There is just one last thing to consider.
Because we are relying on a server response for our data, it is important to consider a situation where our request fails. If this is the case, we need to show our user an appropriate error message.
Let’s do it:
import React, { useState } from "react"; import { ... Alert, AlertIcon, AlertTitle, AlertDescription } from "@chakra-ui/react"; import useCats from "./useCats"; ... const ErrorState = () => { return ( <Alert status="error"> <AlertIcon /> <AlertTitle mr={2}>An error occured!</AlertTitle> <AlertDescription>Please contact us for assistance.</AlertDescription> </Alert> ); }; const TableComponent = () => { const { data, isLoading, isError } = useCats(); if (isError) { return <ErrorState />; } if (isLoading && !data) { return <Spinner />; } if (data?.length === 0) { return <EmptyState />; } return ( <> {isLoading && ( <Progress size="xs" isIndeterminate w="100%" position="fixed" top="0" /> )} <Table colorScheme="blue" overflow="none"> ... </Table> </> ); };
Our component now works perfectly. We have truly thought of every possible scenario. Curious about the code? Play with it yourself here.
Keep in mind that the above is a simplified example based on a single table component. In a real-world app, there might be dozens of pages with many tables and components that all need their own loading, error, and empty states.
So, you may be wondering: is there a way to implement our approach at scale?
In order to implement a scalable data-fetching solution for our client-side rendered React app, we first need to have the right tools for the job at our disposal. Here are some helpful tips.
First, use an established, battle-tested data-fetching library to handle data synchronization and caching. Creating something like this from scratch is both difficult and unnecessary.
There are a number of good options for this. My personal preference is React Query. Once we start using a data-fetching library, it becomes trivial to implement a custom React hook, similar to our useCats
example, for every request we might need to make.
As shown in the example above, these hooks contain all of the information we need to implement each of our component data-fetching states.
Then, take advantage of a component library or, at the very least, build the loading, error, and empty states components with reusability in mind. In our example, we are using Chakra UI for the basic building blocks. But we also separated our ErrorState
and EmptyState
components from the main table, ensuring we can use them again in other places in our app.
Finally, UX/UI consistency across the application is really important for users. To enhance it even further, make sure to standardize the way data-fetching states are handled. This can be done by abstracting even further the conditional rendering logic we used above.
If, for some reason, abstraction is not a good option, make sure to at least strongly encourage standardization throughout the codebase through convention and/or linter rules.
Before we wrap up this overview, it is important to mention that a major React update is in the making — React Suspense for data fetching. This new feature will allow it to declaratively wait for anything asynchronous, including data. It will be, in many ways, a paradigm shift in our thinking about data in React as we switch from a “fetch-on-render” to a “render-as-you-fetch” approach (more information about this shift can be found here).
Let’s re-implement our component with Suspense to see how it works. First, we need to create a fake API for our data.
import data from "./data.json"; export function fetchData() { let catsPromise = fetchCats(); return { cats: wrapPromise(catsPromise) }; } function wrapPromise(promise) { let status = "pending"; let result; let suspender = promise.then( (r) => { status = "success"; result = r; }, (e) => { status = "error"; result = e; } ); return { read() { if (status === "pending") { throw suspender; } else if (status === "error") { throw result; } else if (status === "success") { return result; } } }; } function fetchCats() { console.log("fetch cats..."); return new Promise((resolve, reject) => { setTimeout(() => { console.log("fetched cats"); resolve(data); // reject(); }, 1100); }); }
Note: this is just an example implementation, not production-ready code!
With the API in place, we can add our loading state to the Suspense
fallback.
import React, { Suspense } from "react"; import { ChakraProvider } from "@chakra-ui/react"; import * as ReactDOM from "react-dom"; import Table from "./Table"; import LoadingState from "./LoadingState"; const rootElement = document.getElementById("root"); ReactDOM.createRoot(rootElement).render( <ChakraProvider> <Suspense fallback={<LoadingState />}> <Table /> </Suspense> </ChakraProvider> );
And change our component accordingly:
import React from "react"; import { Table, ... } from "@chakra-ui/react"; import { SearchIcon } from "@chakra-ui/icons"; import { fetchData } from "./fakeApi"; import EmptyState from "./EmptyState" const data = fetchData(); const TableComponent = () => { const cats = data.cats.read(); if (cats?.length === 0) { return <EmptyState />; } return ( <Table colorScheme="blue" overflow="none"> ... </Table> ); }; export default TableComponent;
Nice, we are now using Suspense
for data fetching! Also, as we can see, the empty state implementation remains the same as before.
But what happened to our error handling?
Another interesting change that comes with using Suspense
for data fetching is that we can handle fetching errors the same way we handle rendering errors. For this we will need to use — you guessed it — ErrorBoundary
.
Let’s add it to our app:
import React, { Suspense } from "react"; import { ChakraProvider } from "@chakra-ui/react"; import * as ReactDOM from "react-dom"; import Table from "./Table"; import LoadingState from "./LoadingState"; import ErrorState from "./ErrorState"; import ErrorBoundary from "./ErrorBoundary"; const rootElement = document.getElementById("root"); ReactDOM.createRoot(rootElement).render( <ChakraProvider> <ErrorBoundary fallback={<ErrorState />}> <Suspense fallback={<LoadingState />}> <Table /> </Suspense> </ErrorBoundary> </ChakraProvider> );
Perfect, now we are able to display our error component just as we did before. Check out this Code Sandbox for a working example.
There are several advantages to using these anticipated new features. With their help, we can granularly control error and loading states in our app through the placement of the Suspense
and ErrorBoundary
components.
At the same time, data fetching and rendering logic remain decoupled (fetching kicks off before rendering). With Suspense
, it is also possible to avoid race conditions that often plague more traditional approaches.
While React Suspense is not a data-fetching library by itself, there are steps being taken to integrate it into existing solutions.
These new React updates are very exciting and a lot of fun to play with! But it’s important to remember that it’s all still experimental and subject to change. For the most up-to-date information, you can follow the discussions in the React 18 Working Group repo.
If you found this article useful, follow me on Twitter and visit my blog for more tech content.
Happy coding! ✨
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>
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 nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
One Reply to "UI best practices for loading, error, and empty states in React"
Hi,
Thank you for this great article.
1. How would you handle if cats data is never null, but is returned as an empty array? That makes handling progress more challenging?
2. The suspense – looks interesting, there are some warnings about your implementation and react18. There is no progressBar?
Cheers