React Environment
React does three things that matter to a module:
- it renders twice and throws attempts away -
<StrictMode>, transitions; - it hides committed trees without unmounting them -
<Activity>,<Suspense>; - on the server, it renders without running effects.
A module's life is tied to its place in the tree. So each of these changes when your services are born and buried. This page says what is safe, what is not, and what to do about it.
At a glance
| Environment | Verdict | What you do |
|---|---|---|
<StrictMode> | ✅ Supported | Nothing - leave it on or off, up to your liking. |
| Transitions & abandoned renders | ✅ Supported | Nothing - init purity covers it. |
<Activity> | ❌ Unsupported, permanently | Keep surviving state in a module that doesn't close. |
React.lazy | ✅ Supported | Nothing - lazy modules mount normally. |
<Suspense> | ⚠️ Partially supported | Inner boundary inside the module; suspending updates in a transition. |
| Server rendering | ⚠️ Untested | renderToString appears to work; no battle testing yet. |
<StrictMode> ✅ Supported
On or off - both are supported, up to your liking. Nothing to configure, nothing to work around.
The double render. In dev, StrictMode builds every boundary twice. The extra module is constructed, initialized - and abandoned. It never mounts. It is garbage the collector takes, as long as onModuleInit only allocates. Open a socket there, and you leak one per render.
The simulated remount. StrictMode also replays mount → unmount → mount on the committed module. Same instance. State intact. Nothing is destroyed: destroy waits one macrotask, and the re-mount cancels it first.
Transitions. A transition can render a boundary five times. You get one module, not five. Rebuilds run on commit, in a layout effect - never during render.
<Activity> ❌ Unsupported, permanently
<Activity mode="hidden"> hides a tree by running its effect cleanups. A module's unmount runs on those cleanups, and an unmounted module is destroyed a moment later. So hiding a module destroys it.
If you need state to survive hiding, keep it in a module outside the <Activity> - or skip <Activity> and use conditional rendering as always.
React.lazy ✅ Supported
Modules can be imported as lazy components and loaded behind a <Suspense>. A lazy module runs the same lifecycle as a regular one - laziness changes nothing about init, mount or destroy.
BoardPage.tsx:
import { createModuleComponent, useResolve, withModule } from "@remodulo/react"
const BoardModule = createModuleComponent({ id: "board", providers: [BoardStore] })
function BoardView(): ReactElement {
const store = useResolve(BoardStore)
return <Canvas board={store.board} />
}
export default withModule(BoardModule, BoardView)Importing it:
import { Suspense, lazy } from "react"
const BoardPage = lazy(() => import("./BoardPage"))
export function BoardTab(): ReactElement {
return (
<Suspense fallback={<Skeleton />}>
<BoardPage />
</Suspense>
)
}<Suspense> ⚠️ Partially supported
1. <Suspense> boundaries
Safe only if a <Suspense> boundary inside the module covers every nested route, component and module that can suspend. A suspension nothing catches climbs above the module and hides it - and hiding runs unmount, the Activity problem again.
Example of safe code:
import { Suspense } from "react"
export function BoardRoute({ boardId }: { boardId: string }): ReactElement {
return (
<Suspense fallback={<Skeleton />}>
<BoardModule boardId={boardId}>
{/* Covers everything inside the module that can suspend */}
<Suspense fallback={<Skeleton />}>
<BoardView />
</Suspense>
</BoardModule>
</Suspense>
)
}Example of unsafe code:
export function BoardRoute({ boardId }: { boardId: string }): ReactElement {
return (
<Suspense fallback={<Skeleton />}>
<BoardModule boardId={boardId}>
{/* Nothing catches a suspension here - it hides the module above it */}
<BoardView />
</BoardModule>
</Suspense>
)
}2. startTransition
React never re-hides committed content during a transition. Wrap state changes that might suspend:
import { startTransition, useState } from "react"
export function BoardWorkspace(): ReactElement {
const [boardId, setBoardId] = useState("b-42")
const open = (next: string): void => startTransition(() => setBoardId(next))
return (
<>
<TabStrip onOpen={open} />
<BoardRoute boardId={boardId} />
</>
)
}Server rendering ⚠️ Untested
Untested. renderToString appears to work, but no real battle testing has been done - treat SSR as unverified.
What is certain either way: the server runs no effects, so of the four hooks only onModuleInit fires. Construction and init happen during render; mount, unmount and destroy never run.