Gabriel

A React pattern for consistent loading and loaded states

Published on

Here is the solution I found to make sure my components’ loading state always matches the final (loaded) UI.

The issue: discrepancies between components and their fallbacks

You’ve probably been in this situation: you have a neat <Card /> component, and a wonderfully matching <CardFallback /> component that is used to display the loading state of your card. Except that a few months later, the card has evolved, but you (and, by extension, everyone who worked on that component) forgot to update the fallback component. It no longer quite matches the base component’s UI.

Whatever the reason the fallback component update was overlooked (whether it’s tight deadlines, ignorance that a fallback existed, or anything else really), it would be easier if you didn’t even have to think about keeping the fallback component UI in sync with its counterpart.

I came up with a solution based on what I call shell components.

A simple example

Let’s imagine that you have a component AmountToPayCard that displays the amount your user has to pay for all their invoices, and looks like this:

Amount to pay:

Its code might look something like this:

A parent component, that holds both the base component and its fallback;

function SomeComponent() {
	return (
		<Suspense fallback={<AmountToPayCardFallback />}>
			<AmountToPayCard />
		</Suspense>
	);
}

The base component, that fetches data and displays it;

function AmountToPayCard() {
	const {
		data: { totalAmount },
	} = useInvoicesToPay();

	return (
		<Card>
			<dl>
				<dt>Amount to pay:</dt>
				<dd>{totalAmount}</dd>
			</dl>
		</Card>
	);
}

The fallback component, displayed when the base component suspends.

function AmountToPayCardFallback() {
	return (
		<Card>
			<dl>
				<dt>Amount to pay:</dt>
				<dd>
					<Skeleton />
				</dd>
			</dl>
		</Card>
	);
}

Note that:

A few iterations later, the design has changed, but for some reason, the fallback component was forgotten:

Amount to pay:

The fallback no longer matches the base component, and it causes a layout shift when the loading state ends!

This is a rather barebones example, but it’s enough to highlight the fact that when you update a component, you shouldn’t have to remember to update both the base part and its fallback.

Shell components to the rescue

The idea is to extract the UI of a component into a dedicated component, whose sole purpose is to handle the layout. This component would then be used within a base (loaded) and a fallback (loading) components. An example file tree of such a component could look like this:

amount-to-pay-card/
├── index.jsx
├── fallback.jsx
└── shell.jsx

The code would be as follows:

// amount-to-pay-card/shell.jsx

function AmountToPayCardShell({ children }) {
	return (
		<Card>
			<dl>
				<dt>Amount to pay:</dt>
				<dd>{children}</dd>
			</dl>
		</Card>
	);
}
// amount-to-pay-card/index.jsx

function AmountToPayCard() {
	const {
		data: { totalAmount },
	} = useInvoicesToPay();

	return (
		<AmountToPayCardShell>
			{totalAmount}
		</AmountToPayCardShell>
	);
}
// amount-to-pay-card/fallback.jsx

function AmountToPayCardFallback() {
	return (
		<AmountToPayCardShell>
			<Skeleton />
		</AmountToPayCardShell>
	);
}
Amount to pay:

And now, since the fallback component and the base component are based on the same shell component, their UIs are synchronized. By updating the shell component, you would update the UI of the other components.

Rules to follow

In order for this pattern to be effective, it must be applied as follows:

With multiple pieces of loading data

The example only had a single piece of loading data, so handling it with the children prop was a straightforward solution. But what if the card now displays the number of invoices to pay?

Amount to pay:
invoices

The shell component would then need to receive a second prop with a descriptive name, like invoiceCount. We could also replace the generic children prop with a more specific one, like totalAmount. It would look like this:

function AmountToPayCardShell({ invoiceCount, totalAmount }) {
	return (
		<Card>
			<dl>
				<dt>Amount to pay:</dt>
				<dd>{totalAmount}</dd>
			</dl>
			<div>
				<span>{invoiceCount}</span> invoices
			</div>
		</Card>
	);
}
function AmountToPayCard() {
	const {
		data: { invoiceCount, totalAmount },
	} = useInvoicesToPay();

	return (
		<AmountToPayCardShell
			invoiceCount={invoiceCount}
			totalAmount={totalAmount}
		/>
	);
}
function AmountToPayCardFallback() {
	return (
		<AmountToPayCardShell
			invoiceCount={<SpinnerIcon />}
			totalAmount={<Skeleton />}
		/>
	);
}

As such, each loading part could be handled independantly, and have its own loading and loaded display. In the example above, the invoice count loading state could be represented as a spinner, while the total amount loading state could be represented with its own skeleton.

If you’re using TypeScript, the interface representing the shell component’s props would look like this:

interface Props {
	invoiceCount: number | ReactElement;
	totalAmount: string | ReactElement;
}

Conclusion

Shell components are essentially a way to ensure your loading state looks the same as your loaded state, while also preventing duplication of UI code.

It might be a bit heavy to do, especially for smaller components (like the ones in this article, actually); but the bigger the component, the more moving parts it has, the more useful this pattern gets.

You could try it for a few components, and see if this pattern suits your needs :)