Skeletons are better than spinners. If you're refreshing data, or fetching more, show a spinner. But a screen with no data feels less empty with a skeleton.
If you follow me on Twitter, you know how much I like skeletons. I even added a Skeleton
component to Moti, my animation library for React Native (+ Web).
TLDR
Don't do this:
if (!artist) return <Spinner />
return <Artist artist={artist} />
Instead, let Artist
handle its own loading state.
This gets slightly more complicated when it comes to a list of items. But I'll cover that at the end.
Whenever you build a component that receives data asynchronously, you should make it aware of its 2 distinct states: loading & data.
Develop a Skeleton
mental model
If there's one take-away, it's this: every component with a loading state should render its own placeholder.
I especially love this tweet from Paco Coursey.
Once you have a pretty <Skeleton />
component, it might seem like your work is done.
For example, with Moti's Skeleton, all you have to do is this:
import { Skeleton } from '@motify/skeleton'
const Artist = ({ artist }) => {
const loading = !artist
return (
<Skeleton show={loading}>
<Text>{artist ? artist.name : 'Loading...'}</Text>
</Skeleton>
)
}
Seems easy enough. So we can just use Skeleton
whenever a component has a loading state and we're done, right?
Sure. But let's take it a step further and develop a mental model for building reliable components that display data asynchronously.
We want our components to know definitively if they should show a placeholder state. Thankfully, TypeScript makes this easy.
Adding TypeScript Support
Let's take our Artist
component, and define its loading states outside of the component.
A naΓ―ve implementation might look like this:
type ArtistProps = {
artist: ArtistSchema | null
loading: boolean
}
But this is bad.
Our types should describe the shape of our React state.
However, the code above lets impossible scenarios pass a typechecker.
if (props.loading && props.artist) {
// typescript won't fail here, but it should!
}
Let's change our code to use a type union, and turn boolean
into strict options:
type ArtistProps =
| {
artist: ArtistSchema
loading: false
}
| {
artist?: never
loading: true
}
const Artist = (props) => {
return (
<Skeleton show={props.loading}>
<Text>{!props.loading ? props.artist.name : 'Loading...'}</Text>
</Skeleton>
)
}
Notice that ArtistProps
uses loading: true|false
instead of boolean
.
Whenever props.loading
is true
, TypeScript knows that artist
isn't there. By setting artist?: never
, we ensure that the consuming component can't pass the artist
prop while loading.
Consuming the Artist
component
Artist
receives the artist
and loading
props from a parent. What does that parent look like?
// this is our type from earlier
type ArtistProps =
| {
artist: ArtistSchema
loading: false
}
| {
artist?: never
loading: true
}
// and this is the parent component
const ArtistScreen = () => {
const artist = useSWR('/artist')
return (
<Artist
{...(artist.data
? { artist: artist.data, loading: false }
: { loading: true })}
/>
)
}
Easy. We now have two mutually-exclusive states for our Artist
. When it's loading, show the skeleton. When it's not, show the artist.
Now that we offloaded our logic to TypeScript, we get a delightful developer experience with autocomplete.
You can see what it looks like in the video here:
Lists with Placeholders
The principles for a list are similar to those of a single item.
However, a list should account for 3 states: empty
, loading
, and data
.
const ArtistsList = () => {
const artists = useSWR('/artists')
// pseudo code
const loading = !artists.data
const empty = artists.data?.length === 0
const data = !!artists.data
}
There are 3 possible scenarios:
- no data loaded yet
- data loaded with zero artists
- data loaded with more than zero artists
Lay out the list logic
const ArtistList = () => {
const artists = useSWR('/artists')
if (!artists.data) {
// we still need to make this
return <ArtistListPlaceholder />
} else if (artists.data.length === 0) {
// make this yourself
return <Empty />
}
return artists.map(artist => (
<Artist artist={artist} key={artist.id} loading={false} />
)
}
The only thing left is to make the ArtistListPlaceholder
component.
Create ArtistListPlaceholder
We already have an Artist
component with a potential loading state, so all we need to do is create an array of Artist
components, and pass loading={true}
.
const ArtistListPlaceholder = () => {
// you can adjust this number to fit your UI
const placeholders = new Array(4).fill('')
return placeholders.map((_, index) => (
<Artist
// index is okay as the key here
key={`skeleton-${index}`}
loading
/>
))
}
Our final code for the list looks like this:
const ArtistListPlaceholder = () => {
const placeholders = new Array(4).fill('')
return placeholders.map((_, index) => (
<Artist
key={`skeleton-${index}`}
loading
/>
))
}
const ArtistList = () => {
const artists = useSWR('/artists')
if (!artists.data) {
return <ArtistListPlaceholder />
} else if (artists.data.length === 0) {
return <Empty />
}
return artists.map(artist => (
<Artist artist={artist} key={artist.id} loading={false} />
)
}
I like to put the placeholder in the same file as the list component. It makes it easier to maintain.
The result is a nice list of skeletons:
Fading the list in and out
In the video above, I fade the placeholder list out before fading in the data. That's thanks to Moti's AnimatePresence
component:
Bonus TypeScript utility
Since I use skeletons on many components, I made this type utility to generate their props:
type Never<T> = Partial<Record<keyof T, never>>
export type LoadingProps<PropsOnceLoaded> =
| ({ loading: true } & Never<PropsOnceLoaded>)
| ({ loading: false } & PropsOnceLoaded)
This way, you can easily make components like this:
type Props = LoadingProps<{ artist: ArtistSchema }>
const Artist = (props: Props) => {
// ...
}
Terminology
loading
is often used as a catch-all term to describe fetching initial data, refreshing, and fetching more. If you prefer, you could change the loading
prop to placeholder
in the examples above. It's a preference thing. I like loading
, but I could be convinced that placeholder
is a better name.
Don't use empty
interchangeably with loading
, though, since empty
means a list has loaded with zero items.
I use "placeholder" and "skeleton" a bit interchangeably. Think of a skeleton as the UI that implements the placeholder state.
Placeholders with Suspense
When it comes to suspense, structuring components might be a bit different, since the fallback UI lives outside of the component.
Chances are, you'll do something like this:
const ArtistWithData = () => {
const artist = getArtist()
return <Artist artist={artist} loading={false} />
}
const SuspendedArtist = () => {
return (
<Suspense fallback={<Artist loading />}>
<ArtistWithData />
</Suspense>
)
}
I can't say definitively until Suspense becomes mainstream for data fetching, but I think this pattern will remain. I haven't actually used Suspense much, so if you have other ideas for laying out placeholder content, let me know.
Placeholder text
Here is our original Artist
component:
const Artist = (props) => {
return (
<Skeleton show={props.loading}>
<Text>{!props.loading ? props.artist.name : 'Loading...'}</Text>
</Skeleton>
)
}
Notice that I wrote Loading...
when we're in a loading state.
The Loading...
text will never actually be shown to the user; instead, it's only used to set the width of the skeleton.
Alternatively, you could use a fixed width
, which should apply whenever show
is true
.
<Skeleton width={80} show={props.loading}>
<Text>{props.artist?.name}</Text>
</Skeleton>
Give me your thoughts
That's all. Follow me (Fernando Rojo) on Twitter for more.
Top comments (11)
I think it will be a long way to convince the whole world to switch over from spinners to skeletons haha. But I think that it will be easy with such good libraries like moti.
Also: what your opinion on prefixing boolean variables with is or has? I always find it much easier to read
isLoading
instead ofloading
Iβve seen isLoading used a lot. I agree that itβs more descriptive. For some reason I just like shorter words without camel case, but I definitely see the merit to isLoading.
Prefixing a verb with "is" signals that it's a state/Boolean.
Naming something with just a verb could be confused with a function.
Using shorter words when working on your own is fine, if you're part of a team going against established conventions makes it a pain for everyone else.
Iβm not sure if βloadingβ would ever be considered a function. Youβre free to add βisβ there if you want.
I'm pro-skeleton placeholder.
But Googleβs June 2021 update made us reduce our skeletons drastically due to Cumulative Layout Shift (CLS).
Some of our components weren't matching the height of the skeletons.
The CLS happened when a user navigates to a different view and the response caused the content rendering after the grace period of 500ms. We saw this happening a lot by Indian users with mobile data.
And this prevented us from getting all green URLs for the Core Web Vitals.
We stopped using skeletons for each component and moved to a skeleton per page model.
The height of the skeleton was big enough, that re-rendering it even after the 500ms grace period wouldn't cause any CLS.
Problem solved and we got all the green URLs. β
Hello thank for this very interresting article!
After testing some cases i find out that
Allow us to access to the root of PropsOnceLoaded and not the subfields.
Why allowing root access ? why using
Never<PropsOnceLoaded>
instead of just using{ loading: true }
without it ?What do you think about this one ? seems more convenient for me, don't you think ?
In this way you can't access to the root of the PropsOnceLoaded
Thank you in advance for your response!
This is a fantastic question. The reason is, this way you can destructure your props in the component. Try doing it your way, and then see what happens to TypeScript in the component if you try to destructure props.artist.
By using never or undefined, we allow ourselves to at least see the destructured variables in the component.
However, you are correct. Your example is the safest. Youβll just have to make sure that you always check for props.loading before using props.artist.
Since my example fixes this at the consumption step of the component, it isnβt technically needed.
Hope that helps!
I don't understand where you guys get this weird ideas, not that I don't know what skeleton placeholders are. Sacrificing bundle/app size just to add some fancy loader that doesn't really make much of a difference in the long run.
Thanks for the great article, it was also really nice to learn about usage of TypeScript unions for handling down conditional props
Glad itβs useful!
Am a visual learner and this looks simple though I would prefer you had a video for it.