docs/oss/building-features/react-19-activity.md
<Activity> with React on RailsReact 19.2 introduces <Activity>, a built-in component that lets you hide part of your UI without unmounting it:
import { Activity } from 'react';
<Activity mode={isActive ? 'visible' : 'hidden'}>
<TabPanel />
</Activity>;
When mode="hidden":
display: none.This is the React-blessed replacement for the classic "keep the inactive tab alive" hacks (display: none wrappers, lifting every tab's state up, etc.). Typical uses: tab switchers, master/detail panes, and pre-rendering likely-next screens.
| Layer | Requirement |
|---|---|
react / react-dom in your app | 19.2.0 or later (Activity does not exist in earlier versions, including 19.0/19.1) |
react_on_rails gem + npm package | Any current version — the package's peer dependency is react >= 16, so React on Rails does not constrain you; just upgrade react/react-dom in your app's bundle |
| RSC / Pro streaming path | React on Rails Pro 17 RSC uses React/React DOM 19.2.x with patch >= 19.2.7 and stable react-on-rails-rsc >= 19.2.1 on the 19.2.x line, so <Activity> is available on that coordinated set |
<Activity> is a regular React feature inside your components. React on Rails needs no configuration for it — it works with the standard react_component helper in both client-side rendering and server-side rendering with hydration.
<%= react_component("ActivityTabSwitcher", props: { initialTab: "profile" }, prerender: false) %>
import { Activity, useState } from 'react';
const TAB_NAMES = ['profile', 'drafts'] as const;
type TabName = (typeof TAB_NAMES)[number];
function TabPanel({ tab }: { tab: TabName }) {
// This state survives tab switches because the hidden panel stays mounted.
const [draft, setDraft] = useState('');
return (
<label>
Draft for {tab}: <input value={draft} onChange={(e) => setDraft(e.target.value)} />
</label>
);
}
const ActivityTabSwitcher = ({ initialTab = 'profile' }: { initialTab?: TabName }) => {
const [activeTab, setActiveTab] = useState<TabName>(initialTab);
return (
<div>
{TAB_NAMES.map((tab) => (
<button key={tab} type="button" onClick={() => setActiveTab(tab)}>
{tab}
</button>
))}
{TAB_NAMES.map((tab) => (
<Activity key={tab} mode={tab === activeTab ? 'visible' : 'hidden'}>
<TabPanel tab={tab} />
</Activity>
))}
</div>
);
};
export default ActivityTabSwitcher;
Contrast with the usual conditional render {tab === activeTab && <TabPanel tab={tab} />}, which unmounts the inactive panel and loses its state.
prerender: true)<%= react_component("ActivityTabSwitcher", props: { initialTab: "profile" }, prerender: true) %>
This works with React on Rails' standard string SSR (verified in the dummy app on the ExecJS path; the Pro Node renderer uses the same renderToString API). Verified behavior on React 19.2:
<!--&--> / <!--/&--> boundary markers).Practical consequences:
When a boundary goes hidden, React runs all effect cleanups in that subtree (and re-runs the effects when it becomes visible again). State is preserved; effects are not. Audit hidden-able components for:
useEffect disconnects when the tab hides and reconnects when shown. That is the designed behavior — not a bug. If the connection must outlive visibility, own it above the <Activity> boundary.<Activity> cannot preserve state across Turbo (or Turbolinks) page visits. State preservation only works within a persistent React root. On a Turbo Drive navigation, Turbo replaces the document <body>; React on Rails unmounts your components on the page-change events and mounts fresh ones on the new page. Every <Activity> boundary — hidden or visible — is destroyed with its root, and all React state is gone.
Use the right tool for each axis:
react_component): <Activity> preserves the hidden parts' state. ✅<Activity> does not help. ❌ If you need UI to survive Turbo navigation, the element must be excluded from Turbo's body swap (e.g., Turbo's data-turbo-permanent), which is independent of React and has its own significant constraints with React-managed DOM. For React-native cross-"page" state preservation, use client-side routing (e.g., React Router) inside one persistent React root instead of full Turbo page loads — then <Activity> can keep inactive route trees alive.The React on Rails dummy app contains a complete, tested example:
react_on_rails/spec/dummy/client/app/startup/ActivityTabSwitcher.tsx/client_side_activity (prerender: false)/server_side_activity (prerender: true)react_on_rails/spec/dummy/spec/requests/activity_component_spec.rb (server HTML shape) and react_on_rails/spec/dummy/spec/system/activity_spec.rb (state preservation + hydration in a real browser)