Think about the approaches we have used in React when a component needs to disappear from the screen for a while.

Conditional rendering probably comes to mind first.

{activeTab === "video" ? <VideoPanel /> : null}
{activeTab === "video" && <VideoPanel />}

When the condition becomes false, the component unmounts. Its effects are cleaned up, and its local state and DOM disappear. If the condition later becomes true again, restoring input values, collapsed sections, or scroll position requires some external persistence mechanism.

To preserve that state, you have to keep the component mounted and hide it with CSS or an HTML attribute.

<section hidden={activeTab !== "video"}>
  <VideoPanel />
</section>

This can show the previous UI immediately when the user returns. But because the component remains mounted, its cleanup does not run unless you handle it separately.

<Activity> lets you hide and restore the UI and internal state of its children.

<Activity mode={isActive ? "visible" : "hidden"}>
  <VideoPanel />
</Activity>

Activity behaves as follows:

  • It hides the UI with display: none.
  • It preserves the DOM and React state.
  • It runs Effect cleanup and disconnects subscriptions.
  • While hidden, it renders new props at a lower priority.
  • When it becomes visible again, it reuses the previous state and reconnects Effects.

Instead of completely deleting content that may soon return, Activity keeps its UI and state available for restoration. At the same time, it cleans up Effects so hidden content does not continue causing side effects.

Tabs were the first practical use case that came to mind. Chakra Tabs hides and reveals each panel, and its lazyMount and unmountOnExit options appeared to cover most situations.

Chakra Tabs is implemented like this:

export const TabsContent = withContext(
  ArkTabs.Content,
  "content",
  { forwardAsChild: true },
)

Because Chakra uses Ark UI, we can look directly at Ark UI's Tabs. There, a hook named usePresence controls whether a panel is shown.

usePresence combines lazyMount, visit history, and unmountOnExit in one condition. Reduced to the part relevant here, it looks like this:

const unmounted =
  (!api.present && !wasEverPresent.current && lazyMount) ||
  (unmountOnExit && !api.present && wasEverPresent.current)

const getPresenceProps = () => ({
  hidden: !api.present,
})

After recording whether a panel has ever been present, it returns null instead of the panel when unmounted is true. Otherwise it keeps the div and adds the HTML hidden attribute to inactive panels. The option combinations produce three outcomes.

Every panel mounts immediately. Inactive panels receive hidden. Their state and DOM remain, but their Effects also stay connected.

A panel that has never been visited is null. It mounts the first time it becomes active. After that, the default unmountOnExit={false} keeps its DOM and marks it hidden when the user changes tabs. The HTML hidden attribute effectively behaves like display: none.

Mounting waits until the first visit, and the panel returns to null when the user leaves. Effects are cleaned up, but state and DOM disappear too.

Activity ultimately sets display: none !important on the host element as well. The difference is that React treats Activity as a special internal boundary.

case REACT_ACTIVITY_TYPE:
  return createFiberFromActivity(pendingProps, mode, lanes, key)

React has an engine called the Reconciler, which performs Reconciliation, one of React's core jobs. A Fiber is the unit of that work. For now, it is enough to know that a Fiber records a component's type, props, state, children, pending Effects, and work priority.

When JSX contains <Activity>, the reconciler creates a dedicated ActivityFiber, then creates an internal OffscreenFiber for its children.

const offscreenChildProps = {
  mode: nextMode,
  children: nextChildren,
}

const child = mountWorkInProgressOffscreenFiber(
  offscreenChildProps,
  mode,
  renderLanes,
)

Updates to a hidden tree are sent to OffscreenLane and processed later at lower priority. When mode changes from visible to hidden, React disconnects Effects and then hides the actual DOM. When it changes back, React reveals the DOM and reconnects Effects.

That is the key distinction. A regular unmount disconnects Effects and removes the DOM. A regular hidden element keeps Effects and hides only the DOM. Activity disconnects Effects while preserving and hiding the DOM.

You might wonder whether we could simply hide the component and manage its Effects ourselves.

Suppose we want a video to pause the moment the user leaves its tab. With lazyMount in Chakra Tabs, the panel remains mounted after the user leaves, so VideoPanel must know whether its tab is active.

useLayoutEffect(() => {
  if (!isActive) return

  const video = videoRef.current

  return () => {
    video?.pause()
  }
}, [isActive])

When isActive becomes false, cleanup from the previous Effect pauses the video. It works. But the Tabs' active state now flows all the way into the panel. If that panel also contains timers, editors, or subscriptions, each piece of logic must manage its own connection and cleanup timing around isActive.

With Activity as the boundary, the child does not need to know whether it is active.

useLayoutEffect(() => {
  const video = videoRef.current

  return () => {
    video?.pause()
  }
}, [])

When the parent Activity becomes hidden, React runs this cleanup. When it becomes visible again, React reconnects the Effect even though its dependency array is empty. VideoPanel describes only what to clean up when deactivated, not when deactivation happens.

The important change is where each responsibility lives:

  • The parent that owns Tabs knows which panel is visible.
  • VideoPanel knows which resources it must clean up.
  • Activity connects visibility changes to the Effect lifecycle.

Activity has another use: preparing the next, still-hidden screen at lower priority. The official documentation includes examples that prerender hidden content before tab or page transitions. When the user navigates, the prepared DOM and state can appear immediately.

During server rendering, the children of an Activity with mode="hidden" are omitted from the initial HTML and scheduled for client rendering while the rest of the page hydrates. An Activity boundary also becomes a selective hydration unit, much like Suspense. This lets React hydrate interactive parts of visible content first.

Chakra UI does not yet expose an option that makes Tabs use Activity. Ark UI, however, has added usePresence, Presence, and hideMode="activity" at their call sites, so it may eventually reach Chakra through its Ark UI foundation.

I wanted to try Activity before then, so I placed it inside Tabs myself.

<Tabs.Root
  value={activeTab}
  onValueChange={({ value }) => setActiveTab(value)}
>
  <Tabs.Content value="video">
    <Activity mode={activeTab === "video" ? "visible" : "hidden"}>
      <VideoPanel />
    </Activity>
  </Tabs.Content>
</Tabs.Root>
ConfigurationLazy first visitPreserves stateEffect cleanup
Chakra Tabs defaultNoYesNo
Chakra Tabs default + ActivityNoYesYes
lazyMountYesYesNo
lazyMount + unmountOnExitYesNoYes
lazyMount + ActivityYesYesYes

The previous setup used lazyMount with unmountOnExit. Content could not preload, so users could not see it immediately, and scroll position and input state could not survive a tab change. Switching to Chakra Tabs' default behavior plus Activity brought both preloading and state preservation, which made the experience noticeably better.

Passing active state manually could reproduce similar behavior. But to me, Activity's central contribution is that the React team has given active and inactive states a first-class place in the component lifecycle.