Stop Losing State with new Activity Component

8 min read
react suspense performance

React 19 introduced Activity, a component that lets you hide and restore the UI and internal state of its children without unmounting them. Unlike traditional conditional rendering, Activity preserves component state, DOM state, and optimizes performance through content preloading.


What’s the Purpose of Activity?

The Activity component solves several common React problems:

  1. State preservation: Maintains component internal state when hidden, something conditional rendering with && doesn’t do
  2. DOM preservation: DOM elements remain in the document (using display: none), preserving ephemeral state like form inputs or video playback position
  3. Performance optimization: Allows pre-rendering hidden content and participates in React’s Selective Hydration
  4. Effect management: Cleans up effects when hidden, reducing resource consumption

The syntax is simple:

<Activity mode={visibility}>
    <Sidebar />
</Activity>

Main Use Cases

1. Preserve Component State Between Navigations

Ideal for sidebars, modals, or any component you want to temporarily hide without losing its state:

// ❌ State is lost when unmounted
{
    isShowingSidebar && <Sidebar />
}

// ✅ State is preserved
;<Activity mode={isShowingSidebar ? "visible" : "hidden"}>
    <Sidebar />
</Activity>

2. Maintain DOM State in Forms

Perfect for tabs containing long forms:

<Activity mode={activeTab === "contact" ? "visible" : "hidden"}>
    <ContactForm />
</Activity>

Input values, textareas, and other DOM elements persist when switching tabs.

3. Preload Heavy Content

Allows rendering hidden components with lower priority, starting code and data loading before the component becomes visible:

<Activity mode="hidden">
    <HeavyComponent />
</Activity>

Important: This only works with Suspense-compatible data sources.

4. Improve Hydration Performance

Activity boundaries participate in Selective Hydration, allowing React to hydrate your app in chunks and making interactions possible before everything is hydrated.


Example 1: Video Preloading with Activity

In this example, we’ll preload a video component while it’s hidden, preserving its playback state when the user navigates between tabs:

function VideoPlayer() {
    const ref = useRef()

    // Pause the video when the component is hidden
    useLayoutEffect(() => {
        const videoRef = ref.current
        return () => videoRef.pause()
    }, [])

    return (
        <div>
            <h3>Video Demo</h3>
            <video
                ref={ref}
                controls
                playsInline
                width="100%"
                src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
            >
                Your browser doesn't support the video element.
            </video>
            <p>Playback position is maintained when you switch tabs.</p>
        </div>
    )
}

export default function VideoTabs() {
    const [activeTab, setActiveTab] = useState("home")

    return (
        <div>
            <nav>
                <button onClick={() => setActiveTab("home")}>Home</button>
                <button onClick={() => setActiveTab("video")}>Video</button>
            </nav>

            <Activity mode={activeTab === "home" ? "visible" : "hidden"}>
                <div>
                    <h2>Welcome</h2>
                    <p>Go to the Video tab to see the example.</p>
                </div>
            </Activity>

            <Activity mode={activeTab === "video" ? "visible" : "hidden"}>
                <VideoPlayer />
            </Activity>
        </div>
    )
}

Important note: We use useLayoutEffect instead of useEffect because the cleanup is tied to the visual hiding of the component.


Example 2: Data Preloading with TanStack useSuspenseQuery and Suspense

This example demonstrates how to use Activity with TanStack Query and Suspense to preload API data while the component is hidden:

function UsersList() {
    const { data: users } = useSuspenseQuery({
        queryKey: ["users"],
        queryFn: async () => {
            const response = await fetch("https://jsonplaceholder.typicode.com/users")
            if (!response.ok) throw new Error("Error fetching users")
            return response.json()
        },
    })

    return (
        <div>
            <h2>Users List</h2>
            <p>This data was preloaded while the component was hidden.</p>
            <ul>
                {users.map((user) => (
                    <li key={user.id}>
                        <strong>{user.name}</strong> - {user.email}
                    </li>
                ))}
            </ul>
        </div>
    )
}

export default function DataTabs() {
    const [activeTab, setActiveTab] = useState("home")

    return (
        <div>
            <nav>
                <button onClick={() => setActiveTab("home")}>Home</button>
                <button onClick={() => setActiveTab("users")}>Users</button>
            </nav>

            <Activity mode={activeTab === "home" ? "visible" : "hidden"}>
                <div>
                    <h2>Home</h2>
                    <p>User data is being preloaded in the background.</p>
                </div>
            </Activity>

            <Suspense fallback={<div>Loading users...</div>}>
                <Activity mode={activeTab === "users" ? "visible" : "hidden"}>
                    <UsersList />
                </Activity>
            </Suspense>
        </div>
    )
}

How does preloading work?

  1. When the component mounts, Activity with mode=“hidden” renders UsersList with low priority
  2. useSuspenseQuery initiates the data fetch
  3. Suspense catches the promise and shows the fallback only if the user navigates to the tab before loading completes
  4. When the user clicks “Users”, the data is already loaded and ready to display

Example 3: Multi-Step Form

A common use case is a multi-step form where you want to preserve entered values:

function StepOne({ formData, setFormData }) {
    return (
        <div>
            <h3>Step 1: Personal Information</h3>
            <input
                type="text"
                placeholder="Name"
                value={formData.name}
                onChange={(e) => setFormData({ ...formData, name: e.target.value })}
            />
            <input
                type="email"
                placeholder="Email"
                value={formData.email}
                onChange={(e) => setFormData({ ...formData, email: e.target.value })}
            />
        </div>
    )
}

function StepTwo({ formData, setFormData }) {
    return (
        <div>
            <h3>Step 2: Address</h3>
            <input
                type="text"
                placeholder="Street"
                value={formData.street}
                onChange={(e) => setFormData({ ...formData, street: e.target.value })}
            />
            <input
                type="text"
                placeholder="City"
                value={formData.city}
                onChange={(e) => setFormData({ ...formData, city: e.target.value })}
            />
        </div>
    )
}

export default function MultiStepForm() {
    const [currentStep, setCurrentStep] = useState(1)
    const [formData, setFormData] = useState({
        name: "",
        email: "",
        street: "",
        city: "",
    })

    return (
        <div>
            <h2>Multi-Step Form</h2>

            <Activity mode={currentStep === 1 ? "visible" : "hidden"}>
                <StepOne formData={formData} setFormData={setFormData} />
            </Activity>

            <Activity mode={currentStep === 2 ? "visible" : "hidden"}>
                <StepTwo formData={formData} setFormData={setFormData} />
            </Activity>

            <div>
                {currentStep > 1 && (
                    <button onClick={() => setCurrentStep(currentStep - 1)}>Previous</button>
                )}
                {currentStep < 2 && (
                    <button onClick={() => setCurrentStep(currentStep + 1)}>Next</button>
                )}
                {currentStep === 2 && <button onClick={() => console.log(formData)}>Submit</button>}
            </div>

            <pre>Current state: {JSON.stringify(formData, null, 2)}</pre>
        </div>
    )
}

With Activity, input values are automatically preserved when you navigate between steps, without needing additional state management.


Key Differences from Conditional Rendering

AspectConditional with &&Activity
Component stateLost on unmountPreserved when hidden
DOMDestroyedPreserved (display: none)
EffectsCleaned upCleaned up when hidden
Re-renderingOnly on mountContinuous (low priority)

Conclusion

The Activity component is a powerful addition to React 19 that significantly improves user experience by:

It’s especially useful in applications with tabs, multi-step wizards, dynamic sidebars, or any scenario where you need to temporarily hide UI without losing its state.


References