A React pattern for consistent loading and loaded states
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:
- the
Card
component here is only used for styling purposes useInvoicesToPay
is a custom hook responsible for fetching data about invoices to pay, and suspending the component until the data is available (it could be, for instance, a light wrapper around theuseSuspenseQuery
hook from the excellent Tanstack Query library)
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:
- there must be a shell component, and base and fallback ones that consume this shell component
- the shell component must contain no logic at all (it should be “dumb”)
- each part of the shell component that would hold loading data must be received as a prop that could be either a primitive value (a string, a number, etc.) or a
ReactElement
(for example, a skeleton)
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:
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 :)