30 septembre 2025
Activity, the new React component

4 minutes reading

Freshly announced on the React canary channel, the new Activity component aims to make conditional rendering of components much easier, while allowing their state to be preserved even while visually hidden. Let’s explore how it works.
Installation
Let’s start by creating a simple React project using Vite:
bun create vite@latest
We’ll need to update the package.json
to use the canary version of React and React DOM.
Use case
Let's implement a frequent use case: managing a multi-step form.
Basic usage
Up to now, the typical way to handle this would look like:
function App() {
const [step, setStep] = useState(1);
return (
<div
style={{
margin: '1rem',
display: 'flex',
alignItems: 'center',
flex: 1,
flexDirection: 'column',
gap: '1rem',
}}
>
<div style={{ display: 'flex', gap: '1rem' }}>
<button type="button" onClick={() => setStep(1)}>
Step 1
</button>
<button type="button" onClick={() => setStep(2)}>
Step 2
</button>
</div>
{step === 1 && <Step1 />}
{step === 2 && <Step2 />}
</div>
); }
const Step1 = () => {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
name="name"
placeholder="Name"
/>
);
};
const Step2 = () => {
const [address, setAddress] = useState("");
return (
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
name="address"
placeholder="Address"
/>
); };
While this code works, it has an important UX drawback: if you fill in a field and then navigate to the next step, the input’s state is lost. This happens because the component is unmounted. To avoid losing state, we could instead hide the component rather than unmounting it, ensuring it always remains in the React tree.
function App() {
const [step, setStep] = useState(1);
return (
<div
style={{
margin: "1rem",
display: "flex",
alignItems: "center",
flex: 1,
flexDirection: "column",
gap: "1rem",
}}
>
<div style={{ display: "flex", gap: "1rem" }}>
<button type="button" onClick={() => setStep(1)}>
Step 1
</button>
<button type="button" onClick={() => setStep(2)}>
Step 2
</button>
</div>
<div style={step === 2 ? { display: "none" } : {}}>
<Step1 />
</div>
<div style={step === 1 ? { display: "none" } : {}}>
<Step2 />
</div>
</div>
);
}
This approach works, but it’s not ideal—especially if your components contain side-effects, event listeners, or interactive content such as video. You’d need to manage activation/deactivation yourself using props, including controlling your effect hooks (like useEffect
or useLayoutEffect
) based on those props. Although that’s feasible, it quickly adds complexity.
Enter the Activity component
This is precisely where the Activity
component comes in. Its job is simple: it hides a DOM node with display: none
, keeps the React node's state intact, and only executes effects when the component is visible. In effect, it allows your component to be “partially” mounted and unmounted.
function App() {
const [step, setStep] = useState(1);
return (
<div
style={{
margin: "1rem",
display: "flex",
alignItems: "center",
flex: 1,
flexDirection: "column",
gap: "1rem",
}}
>
<div style={{ display: "flex", gap: "1rem" }}>
<button type="button" onClick={() => setStep(1)}>
Step 1
</button>
<button type="button" onClick={() => setStep(2)}>
Step 2
</button>
</div>
<Activity mode={step === 1 ? "visible" : "hidden"}>
<Step1 />
</Activity>
<Activity mode={step === 2 ? "visible" : "hidden"}>
<Step2 />
</Activity>
</div>
);
}
Now, text input is preserved when switching between steps. If you inspect the DOM, you’ll see that a hidden input is simply set to display: none
.
To dig deeper, let’s look at how effects behave with some simple console.log
s.
const Step2 = () => {
const [address, setAddress] = useState("");
useEffect(() => {
console.log("Step2 mounted");
return () => {
console.log("Step2 unmounted");
};
}, []);
useLayoutEffect(() => {
console.log("Step2 layout mounted");
return () => {
console.log("Step2 layout unmounted");
};
}, []);
return (
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
name="address"
placeholder="Address"
/>
);
};
If you reload the page, you’ll notice none of the logs are printed, even though the input exists in the DOM. When you navigate to step 2, the effects run, and if you go back to step 1, their cleanup functions are called.
Pre-rendering and remote data
To fetch remote data, a common approach is to use a useEffect
. However, as we've seen, effects do not run for hidden components wrapped with Activity
. To handle this, we can use the use
hook.
const fetchAddress = new Promise<string>((resolve) => {
resolve("Some street name");
});
const Step2 = () => {
const addressData = use(fetchAddress);
const [address, setAddress] = useState(addressData);
return (
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
name="address"
placeholder="Address"
/>
);
};
With this setup, the fetchAddress
promise is fired as soon as Step2
gets instantiated, and the resolved value appears in your input—right at page load.
Conclusion
This brand new Activity
component will make certain complex use cases much more straightforward to handle in your apps. It's extremely intuitive to use, though you may need to refactor parts of existing projects—especially where effect behavior changes.
Have an idea for a React project or need some support? Get in touch with us!
Resources: