DEV Community

Cover image for Stop using loading spinners
Fernando Rojo
Fernando Rojo

Posted on • Updated on

Stop using loading spinners

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} />
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 })}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

There are 3 possible scenarios:

  1. no data loaded yet
  2. data loaded with zero artists
  3. 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} />
  )
}
Enter fullscreen mode Exit fullscreen mode

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
    />
  ))
}
Enter fullscreen mode Exit fullscreen mode

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} />
  )
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

This way, you can easily make components like this:

type Props = LoadingProps<{ artist: ArtistSchema }>

const Artist = (props: Props) => {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Give me your thoughts

That's all. Follow me (Fernando Rojo) on Twitter for more.

Discussion (11)

Collapse
cstrnt profile image
Tim Raderschad

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 of loading

Collapse
nandotherojo profile image
Fernando Rojo Author

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.

Collapse
reikrom profile image
Rei Krom

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.

Thread Thread
nandotherojo profile image
Fernando Rojo Author

I’m not sure if ‘loading’ would ever be considered a function. You’re free to add ‘is’ there if you want.

Collapse
zwacky profile image
Simon Wicki

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. ✅

Collapse
amirault profile image
Tony Amirault • Edited

Hello thank for this very interresting article!

After testing some cases i find out that

export type LoadingProps<PropsOnceLoaded> =
    | ({ loading: true } & Never<PropsOnceLoaded>)
    | ({ loading: false } & PropsOnceLoaded)
Enter fullscreen mode Exit fullscreen mode

Allow us to access to the root of PropsOnceLoaded and not the subfields.
Why allowing root access ? why usingNever<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 ?

export type LoadingProps<PropsOnceLoaded> =
    | ({ loading: true })
    | ({ loading: false } & PropsOnceLoaded)
Enter fullscreen mode Exit fullscreen mode

In this way you can't access to the root of the PropsOnceLoaded

Thank you in advance for your response!

Collapse
nandotherojo profile image
Fernando Rojo Author

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!

Collapse
joshuaamaju profile image
Joshua

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.

Collapse
walkeryr profile image
Yuriy Khamzyaev

Thanks for the great article, it was also really nice to learn about usage of TypeScript unions for handling down conditional props

Collapse
nandotherojo profile image
Fernando Rojo Author

Glad it’s useful!

Collapse
scottlexium profile image
Scottlexium

Am a visual learner and this looks simple though I would prefer you had a video for it.