Components hidden by Activity will maintain their state while also unmounting their effects and continue to preload suspenseful data—here's what makes that useful.
Activity is a component currently available in the experimental build of React 19. It allows you to toggle the visibility of a component, and it has a few side benefits that make it more useful than alternative approaches.
Watch a short video of this blog post X
An <Activity>
boundary can control whether its children are hidden or visible depending on the value of its mode
prop. The secret sauce Activity provides is that components will unmount their effects while maintaining their state when hidden. While still performing suspenseful data fetching, but at a lower priority than visible components.
This is useful in a scenario I've run into multiple times recently while working with a suspenseful data fetching library.
Goal: How do I enable a parent element to control the visibility of child components when only the children contain the logic to do so?
The app in this example is a simple table view. It's currently fetching a list of courses from Sanity Learn. We need the radio buttons at the top of the table to determine the visibility of the rows in the table. Let's call these our filter values.
Typically, this would be as simple as fetching all data in the parent, filtering that data depending on the value of the radio buttons, and conditionally rendering each child. See "alternative approaches" to learn what we lose when we do that.
Sanity App SDK for React is the suspense-enabled data fetching library I'm using for this app.
The useDocuments
hook returns an array of "document handles," identifiers for a document within a Sanity Dataset that only contain the document's ID and Type values.
So while the parent knows the current value of the radio buttons filter
, the data to compare it to is only known within each child row. Therefore, the parent element can't conditionally render its children because it doesn't see the value of each child's "visibility."
A simplified version of the parent component looks like this: we fetch all documents of the type course
and map over these document handles to render each child row.
This component receives the filter
from the table and passes it down to each child row.
import { Suspense } from 'react'import { useDocuments } from '@sanity/sdk-react'import { Course } from './course'import { TableWrapper } from './table-wrapper'import { RowSkeleton } from './row-skeleton'
export function Courses({ filter }: { filter: string }) { const { data } = useDocuments({ documentType: 'course', })
return ( <TableWrapper> {data?.map((course) => ( <Suspense key={course.documentId} fallback={RowSkeleton} > <Course handle={course} filter={filter} /> </Suspense> ))} </TableWrapper> )}
A simplified version of the child component being rendered for each row looks like this, where the value of the "visibility" is known. So now it can be compared to the parent component's filter
and is conditionally visible or hidden.
import { unstable_Activity as Activity } from 'react'import type { DocumentHandle } from '@sanity/sdk'import { useDocument } from '@sanity/sdk-react'import { TableRow, TableCell } from './table'import { Badge } from './badge'
type CourseProps = { handle: DocumentHandle filter: string}
type Course = { title: string | null visibility: string | null}
export function Course({ handle, filter }: CourseProps) { const { data } = useDocument<Course>({ ...handle })
const visibility = data?.visibility ?? 'public' const mode = filter === 'all' || filter === visibility ? 'visible' : 'hidden'
return ( <Activity mode={mode}> <TableRow> <TableCell className="font-medium"> {data?.title ?? ''} </TableCell> <TableCell className="text-right"> <Badge>{visibility}</Badge> </TableCell> </TableRow> </Activity> )}
So now we have a table where the parent controls the filter
value and child components determine their own visibility.
While our table is this simple perhaps this approach seems unnecessary, however as complexity increases the benefits of Activity become more relevant.
Activity is a convenience component with some nice features, but we could've done mostly the same thing without it. Here's the approaches it replaces.
Truthfully, we could've added a filter
to the parent component to modify the list of returned documents.
// filter in the parent, re-rendering children on visibilityimport { Suspense } from 'react'import { useDocuments } from '@sanity/sdk-react'import { Course } from './course'import { TableWrapper } from './table-wrapper'import { RowSkeleton } from './row-skeleton'
export function Courses({ filter }: { filter: string }) { const { data } = useDocuments({ documentType: 'course', filter: 'visibility == $filter || $filter == "all"', params: { filter }, })
return ( <TableWrapper> {data?.map((course) => ( <Suspense key={course.documentId} fallback={RowSkeleton} > <Course handle={course} /> </Suspense> ))} </TableWrapper> )}
This would result in mounting/unmounting, instead of showing/hiding filtered documents.
This also removes the preloading benefits we gain from Activity still running suspenseful fetches within hidden elements, and would reset the local state when unmounting hidden components.
And this only works (with this particular data fetching library) because visibility
is a piece of data in each document. If we wanted to determine visibility by some deeply nested value—or one not contained in the document—we'd need another approach.
We also could've used a different data fetching hook to retrieve all the data required to render the parent and its children. App SDK for React provides useQuery
to do that.
// fetch everything in the parent, less performantimport { Suspense } from 'react'import { useQuery } from '@sanity/sdk-react'import { Course } from './course'import { TableWrapper } from './table-wrapper'import { RowSkeleton } from './row-skeleton'
export function Courses({ filter }: { filter: string }) { const { data } = useQuery({ query: ` *[ _type == "course" && visibility == $filter || $filter == "all" ]{ _id, title, visibility }`, params: { filter }, })
return ( <TableWrapper> {data?.map((course) => ( <Suspense key={course._id} fallback={RowSkeleton}> <Course document={course} /> </Suspense> ))} </TableWrapper> )}
But in a large dataset this might return thousands of documents. Rendering and refreshing the entire response is not performant for a real-time application.
We would also be once again unmounting rows that are no longer visible, resetting their state.
An <Activity>
boundary is not required for every component that is conditionally rendered. But for instances where maintaining its state, unmounting effects or preloading data fetches is useful—or when a child needs to determine its own visibility—it worked for me!