Skip to content

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

EnvironmentVerdictWhat you do
<StrictMode>✅ SupportedNothing - leave it on or off, up to your liking.
Transitions & abandoned renders✅ SupportedNothing - init purity covers it.
<Activity>❌ Unsupported, permanentlyKeep surviving state in a module that doesn't close.
React.lazy✅ SupportedNothing - lazy modules mount normally.
<Suspense>⚠️ Partially supportedInner boundary inside the module; suspending updates in a transition.
Server rendering⚠️ UntestedrenderToString 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:

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:

tsx
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:

tsx
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:

tsx
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:

tsx
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.

Where to go next

Guides

Reference

MIT licensed.