Guides
Server and Client Model
Mreact renders routes on the server by default and only ships browser runtime when a rendered code path needs it. Use this page to decide whether a feature should stay server-only, become client-interactive through inference, use an explicit client boundary, or turn the whole route into a client route.
The package you import does not by itself determine the execution mode. Native cell(), computed(), and effect() APIs are the recommended starting point for new App Router code; compatibility hooks remain available for React-style authoring, migrations, and verified ecosystem integrations. The compiler and route boundary determine what executes on the server and what hydrates in the browser.
Server by default
A route with no client behavior can stay JavaScript-free in the browser. Loaders, route handlers, metadata functions, middleware, and server-rendered components run on the server side of the route graph.
When a server-rendered route uses Link from @reckona/mreact-router/link, Mreact can still inject the navigation runtime so link prefetching, scroll behavior, and client navigation work without hydrating the whole route. Use navigationRuntime only when you need to override that automatic behavior.
When code needs the browser
A component path becomes client-interactive when its render path reaches browser behavior such as reactive state, event handlers, browser globals, or a route-level "use client" directive. The surrounding route can still render on the server unless the route module itself opts into browser ownership.
import { cell } from "@reckona/mreact-reactive-core";
export default function CounterPage() {
const count = cell(0);
return (
<main>
<p>{count.get()}</p>
<button onClick={() => count.set((value) => value + 1)}>+1</button>
</main>
);
}Mreact infers common client needs from supported route shapes and app-local static imports. This is not a general semantic or TypeScript type-flow analyzer; use an explicit boundary when the browser work is hidden behind a dynamic registry, indirect dispatch, or another shape the analyzer cannot follow.
Post-commit DOM lifecycle
Use domRef on an intrinsic element when an imperative browser API needs the committed DOM node. Mreact infers client interactivity from domRef, calls the callback only after the current element is connected, and runs the returned cleanup before that element is replaced, removed, or discarded by route navigation.
export default function Chart() {
return (
<div
domRef={(element) => {
const chart = mountChart(element);
return () => chart.destroy();
}}
/>
);
}Server string and stream rendering omit domRef without evaluating its callback. During hydration, the callback receives the retained SSR element rather than a detached client clone. domRef is supported on intrinsic elements only; pass an explicit callback prop when a component needs to expose one of its elements. The callback identity is not reactive, so use a keyed or conditional replacement when a different lifecycle owner must attach.
Low-level DOM work has an explicit owner contract: keep the disposer returned by createRoot() or register each resource with createCleanupScope(). The owner covers DOM bindings, event listeners, subscriptions, effects, and pending tasks; this makes repeated mount and unmount cycles return to a stable live-resource baseline.
Client boundaries
Start with ordinary app-local components. Mreact can infer client interactivity from supported static render shapes, so a page can remain server-rendered while a nested control hydrates in the browser.
// src/app/widgets/LikeButton.tsx
import { cell } from "@reckona/mreact-reactive-core";
export default function LikeButton() {
const likes = cell(0);
return (
<button onClick={() => likes.set((value) => value + 1)}>
{likes.get()} likes
</button>
);
}// src/app/widgets/page.tsx
import { LikeButton } from "./LikeButton.client.js";
export default function WidgetsPage() {
return (
<main>
<h1>Widgets</h1>
<p>This page shell stays server-rendered.</p>
<LikeButton />
</main>
);
}Prefer inference for ordinary counters, menus, and small controls. Use .client.tsx when inference cannot see the browser work, when the boundary is a useful part of the design, or when you are isolating an integration that should be treated as explicitly browser-only.
Reactive update scheduling
cell.set() updates the stored value immediately, invalidates derived values synchronously, and schedules effects and compiler-emitted DOM bindings in a microtask. Reads through .get() see the latest value before that flush runs.
Use batch() for synchronous transactions and batchAsync() only for intentionally scoped async transactions. batchAsync() keeps effect flushing suspended across every awaited step and releases the queued work once the callback resolves or rejects, so avoid wrapping long I/O or user interaction flows.
import { batchAsync, cell, effect } from "@reckona/mreact-reactive-core";
const count = cell(0);
effect(() => {
console.log(count.get());
});
await batchAsync(async () => {
count.set(1);
await Promise.resolve();
count.set(2);
});Route-level "use client"
Use route-level "use client" when the whole page, layout, or template should hydrate as a client route.
"use client";
export default function EditorPage() {
return <main>{startClientOnlyEditor()}</main>;
}This is also the escape hatch when browser behavior is intentionally hidden behind dynamic dispatch and should not rely on inference.
SSR fallback behavior
Client-interactive boundaries still participate in server rendering. Mreact emits a boundary marker and serialized props, and server-renderable children can remain visible in the initial HTML so the page can paint before hydration.
Guard browser globals directly when a component should keep SSR fallback content:
export function CurrentPath() {
if (typeof window === "undefined") {
return <span>Current path unavailable during SSR</span>;
}
return <span>{window.location.pathname}</span>;
}Unguarded browser access, or guards hidden behind aliases the analyzer cannot follow, can produce a placeholder-only boundary. Prefer direct typeof window === "undefined" guards for server-renderable fallback paths.
Choosing the right boundary
- Static page: server route.
- Page with
Linknavigation only: server route plus navigation runtime. - Small interactive widget: inferred client-interactive component.
- Explicit integration boundary:
.client.tsx. - Whole page is browser-owned: route-level
"use client". - Browser behavior is hidden behind dynamic dispatch: explicit
.client.tsxor"use client".
Enforcing an execution contract
Projects that need a build-time guard can pass executionContracts to buildApp(). The option is opt-in; leaving it out keeps the normal inference and compatibility behavior.
await buildApp({
appDir: "src/app",
outDir: ".mreact",
executionContracts: {
serverOnlyRoutes: ["/account/**"],
zeroClientJsRoutes: ["/articles/**"],
maxInitialJsGzipBytes: { "/search": 20_000 },
noCompatComponents: ["src/components/**"],
},
});serverOnlyRoutes rejects client, shared, or unresolved boundaries reachable from a matching route. noCompatComponents rejects filename-based compatibility fallbacks and unresolved components whose normalized file or export matches the pattern. * matches one path segment and ** matches across segments. Matching is evaluated during source analysis, before production artifacts are emitted, so a failed contract does not silently produce a build with a different execution mode.
zeroClientJsRoutes is a separate delivery contract: a server-only route can still ship the navigation runtime. maxInitialJsGzipBytes sets a compressed initial JavaScript budget by route pattern. Both contracts inspect the production client manifest and its static import closure, including navigation JavaScript and excluding CSS; they are checked again when an incremental build reuses artifacts. These are per-artifact gzip estimates, not observed network transfer sizes. The standalone boundaries command cannot enforce delivery contracts because it has no production artifacts. Routes that must deliver no JavaScript may need navigationRuntime = false in their route module.
Use these contracts when deployment or ownership rules require an explicit boundary policy. Keep the patterns narrow and review them with the generated boundary report so a new client dependency or compatibility fallback fails close to its source.
Inspect a compiler transform
explainTransform() returns a versioned JSON report for one source file. It includes compiler decisions with source positions and reason codes, diagnostics, runtime imports, emitted code, and the current heuristic source map. An editor or CI tool can consume this report alongside the route boundary report; it does not yet trace a DOM element back to JSX or attribute route bundle bytes to an individual expression.
import { explainTransform } from "@reckona/mreact-compiler";
const report = explainTransform({
code: source,
filename: "src/components/Search.tsx",
target: "client",
dev: false,
});
console.log(JSON.stringify(report, null, 2));Related pages
Request-scoped state during SSR
Module-level cell() values and ordinary module variables live as long as the server process or Worker isolate. Writing a user's loader data into such a cell can expose it to the next request, including when that request's loader returns null. Development module re-evaluation can hide this problem; test repeated requests against the same production artifact.
Use requestState() from @reckona/mreact-reactive-core when several components or render helpers share mutable state. Declare the accessor at module scope and create the entire state graph inside its initializer, including cells, computed values, arrays, maps, and helper objects:
import { cell, computed, requestState } from "@reckona/mreact-reactive-core";
const usePageState = requestState(() => {
const name = cell<string | null>(null);
const hasProfile = computed(() => name.get() !== null);
return { name, hasProfile };
});
export default function Page({ data }: { data: { name: string } | null }) {
const state = usePageState();
if (data !== null) state.name.set(data.name);
return <main>{state.name.get()}</main>;
}The initializer runs lazily once per server request. A later request starts from fresh state even if its loader returns null. Nested components can call the same accessor instance to obtain that request's state. Loader and render code can be compiled into separate artifacts with separate module instances; return loader data and initialize render state from props instead of using module state as a loader-to-page transport. Put derived computed() values inside the initializer too: a module-level computed value has its own shared cache. Allocate mutable initial values inside the initializer rather than returning an object created outside it. Ordinary module variables and existing cell() declarations are not automatically isolated.
The Node and Cloudflare App Router renderers establish the scope before application rendering and retain it for response body reads. State belongs to the render request independently of any query client supplied by the application. Computed values, effects, and other resources that register with the initializer's cleanup owner survive re-evaluation or disposal of the component that first calls the accessor. The request disposes these resources when its response body finishes, errors, or completes cancellation; responses without a body and failed renders dispose them immediately. Automatic ownership covers synchronous initialization, not resources created after an await or arbitrary objects with a dispose() method.
Outside a server request scope, calling the accessor throws. After a request ends, initializing another accessor also throws, and its disposed computed values can no longer be read. Previously initialized plain values remain associated with the original scope; this does not extend the lifetime of their reactive resources. In the browser, the same accessor lazily creates one ordinary state graph shared for the page lifetime, independently of its callers' cleanup scopes; initialize it from loader props during hydration. This API does not serialize its state.
Cloudflare requires native AsyncLocalStorage for requestState(). Enable nodejs_compat or install a supported storage explicitly. The adapter also discovers AsyncLocalStorage through process.getBuiltinModule() when the runtime provides it. Without native storage, reading request state throws; ordinary routes and streaming responses that do not use this API continue to work. A serialized global fallback cannot safely isolate detached asynchronous callbacks. Keep request work awaited; for detached background work, resolve the accessor during the request and pass the required values explicitly instead of calling the ambient accessor later.
Custom server integrations can install AsyncLocalStorage with installRequestStateStorage(new AsyncLocalStorage()), then call runWithRequestState(() => render()). Each call creates a fresh scope, including nested calls, and disposes initializer resources when the callback returns or its promise settles. Await all asynchronous work within the callback, including consuming or cancelling a stream; returning a Response alone does not extend this callback lifetime. The built-in App Router adapters retain their own scope until the streamed response ends automatically.