Use suspense boundaries to wrap data—not layouts—to avoid its biggest problem.
The React docs for Suspense begin with this example:
;<Suspense fallback={<Loading />}> <SomeComponent /></Suspense>
...and truthfully, Suspense really is that simple to implement. It's great.
As long as <SomeComponent />
suspends, <Loading />
will render in its place. Components suspend when loaded with React's lazy
function or contain suspense-compatible data fetching.
But, there's something about the API design of separating the fallback
and children
props that too easily leads to poor implementations. You've seen it every time you've logged into just about any React-powered dashboard, and it has a name: layout shift.
Side note: If you think it's contrived to take the most basic demo from the documentation and complain it falls short, explain to me why every utility company, school, healthcare provider or airline's web app is a target-rich environment for examples of layout shift as a result of async data fetching and rendering.
This docs example is actually a good one, most simplified Suspense examples look something like this—where fallback
is a string—which is guaranteed to result in layout shift unless a string is all your suspended child returns.
;<Suspense fallback="Loading..."> <SomeComponent /></Suspense>
Let's look at a filtered list being rendered into a table view.
In this example I'm using the Sanity App SDK, which performs suspenseful data fetching, to render a list of courses from Sanity Learn.
The fastest (and worst) way to implement this is to fetch all our data in a parent component, suspend rendering the entire table until we have the data and render a fallback
string until the fetch has resolved.
// ❌ Bad: `fallback` should almost never be a string// ❌ Bad: Suspense is wrapping an entire layout
import { useDocuments } from '@sanity/sdk-react'import { Suspense } from 'react'
export function BadSuspenseDemo() { return ( <Suspense fallback="loading..."> <CoursesTable /> </Suspense> )}
function CoursesTable() { const { data } = useDocuments({ documentType: 'course', })
return <Table data={data} />}
Another bad fallback
prop is the "loading spinner," not because spinners are inherently bad but because they do not reserve the same space as the child component that will eventually render.
// ❌ Bad: LoadingSpinner is not the same size as CoursesTable
import { Suspense } from 'react'import { LoadingSpinner } from './components/loading-spinner'
export function BadSuspenseDemo() { return ( <Suspense fallback={<LoadingSpinner />}> <CoursesTable /> </Suspense> )}
To solve layout shift with Suspense we need to make sure that the fallback
prop and children
render components that are the same size.
There are two problems with the approach above.
fallback
prop is not the same size as the children
.<Suspense>
boundary is too far up the component tree from the data we're rendering.If we only try to solve the first problem, the second one becomes more apparent.
Avoiding layout shift while the table is suspended would require creating a skeleton version of the entire table that shares all of the same layout components as the eventually rendered list.
Don't use Suspense to suspend the layout, use it to suspend rendered data. The useDocuments
hook shouldn't suspend components like the table headers or filters from being visible—just the rows of data they're fetching.
In the updated code below, we only need to maintain fallback
and children
versions of a table row instead of the entire table layout.
// ✅ TableWrapper renders synchronously// ✅ TableRowSkeleton swaps out for TableRowData
import { useDocuments } from '@sanity/sdk-react'import { Suspense } from 'react'import { TableWrapper } from './table-wrapper'import { TableRowSkeleton } from './table-row-skeleton'import { TableRowData } from './table-row-data'
export function GoodSuspenseDemo() { return ( <TableWrapper> <Suspense fallback={<TableRowSkeleton />}> <Courses /> </Suspense> </TableWrapper> )}
function Courses() { const { data } = useDocuments({ documentType: 'course', })
return data.map((course) => ( <TableRowData key={course.documentId} handle={course} /> ))}
So now the entire table is visible at first render.
While the data fetch suspends, we render 10 "skeleton" rows:
export function TableRowSkeleton() { const Row = () => ( <TableRow className="animate-pulse opacity-50"> <TableCell className="font-medium"> Loading... </TableCell> <TableCell className="text-right"> <select disabled className="w-full"> <option value="">Loading...</option> </select> </TableCell> </TableRow> )
return Array.from({ length: 10 }).map((_, index) => ( <Row key={index} /> ))}
Which share the same basic layout elements as each row which is rendered once the data fetch resolves.
Because they share the same elements, such as text size and the select component, they are both the same size and will render without our layout shift when resolved.
import { TableCell, TableRow } from './table'
export function TableRowData(props: { title: string visibility: string}) { return ( <TableRow className="animate-pulse opacity-50"> <TableCell className="font-medium"> {props.title} </TableCell> <TableCell className="text-right"> <select className="w-full"> <option>{props.visibility}</option> </select> </TableCell> </TableRow> )}
These TableRowSkeleton
and TableRowData
components are much simpler to maintain than trying to recreate the entire table in pending and resolved states.
Admittedly, there are some small issues with this approach. For example, we're rendering 10 skeleton rows when the final resolved data may contain more or less than that. It's a best guess.
There are times when layout shifts will be unavoidable. That's where some UI trickery or perhaps even animation could be involved.
It's possible to wrap suspending elements in a view transition to animate between states, and this may soften the blow of visual changes once a Suspense boundary renders its children, but that's for another blog post.