Overview

Basics

Mreact is a React-flavored framework with an app router, server-first rendering, and fine-grained reactive primitives for client interactivity. Start here when you want the mental model before choosing a route shape, a client boundary, or a state primitive.

The core model

A Mreact app is a route tree. Route files render on the server by default, layouts wrap pages, and route module exports such as metadata, loader, action, and route handlers describe the work that belongs to the request.

Client JavaScript is inferred from the code that needs it. Event handlers, reactive state, and direct browser APIs usually make only the component path that uses them client-interactive, while the surrounding route can stay server-rendered. Use .client.tsx or route-level "use client" when you want to make that boundary explicit or when the whole screen should run in the browser.

For a new Mreact application, begin with the compiled App Router model and native primitives: use cell() for writable state, computed() for derived values, and effect() for imperative side effects. The package names describe authoring APIs, while the compiler decides which parts execute on the server and which parts hydrate in the browser.

| Need | Starting point | Execution and ownership | | --- | --- | --- | | New Mreact UI | App Router with cell, computed, and effect | Native compiled boundaries; keep state inside the component or cleanup owner that owns it. | | React-style code or an ecosystem library | @reckona/mreact-compat or @reckona/mreact compatibility exports | Compatibility execution is appropriate for migration and verified library scope; test the behavior you depend on. | | Direct DOM or imperative integration | @reckona/mreact-reactive-dom with createRoot() and explicit disposal | The root or createCleanupScope() owns bindings, listeners, subscriptions, and other resources. |

The native and compatibility paths can implement the same counter behavior. Native authoring uses tracked cell reads:

import { cell } from "@reckona/mreact-reactive-core";

export function NativeCounter() {
  const count = cell(0);
  return <button onClick={() => count.set((value) => value + 1)}>{count.get()}</button>;
}

React-style authoring uses the compatibility hook contract:

import { useState } from "@reckona/mreact";

export function CompatCounter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((value) => value + 1)}>{count}</button>;
}

Both examples describe a counter; optimization and fallback decisions may change the shipped work, but they preserve the observable state and event behavior covered by the selected API contract.

Reactive values

Use cell() for writable state. A cell stores one value, .get() reads it, and .set() writes either a replacement value or an updater function.

import { cell } from "@reckona/mreact-reactive-core";

export default function Counter() {
  const count = cell(0);

  return (
    <button type="button" onClick={() => count.set((value) => value + 1)}>
      Count: {count.get()}
    </button>
  );
}

Define local cells inside the component or owner function that owns the state. Top-level cells are shared module state, so reserve them for deliberate singletons such as app-wide stores or caches. Keeping count inside Counter gives each component instance its own counter and avoids accidental sharing across route renders or multiple mounted counters.

Reads are dependency-tracked while reactive work is running. When count.set() runs, Mreact updates the stored value immediately, invalidates derived values synchronously, and schedules DOM bindings and effects in a microtask.

If you are coming from React, count.get() may look less natural than reading a state variable directly. The method call is intentional: it is the explicit tracked read that lets Mreact record the current cell as a dependency of the active render, computed(), effect(), or DOM binding. That recorded dependency graph is how Mreact knows exactly which derived values and bindings to update after count.set() changes the value.

Derived values

Use computed() when a value can be derived from other cells. A computed value is readonly and recalculates when the cells it reads change.

import { cell, computed } from "@reckona/mreact-reactive-core";

export default function ProfilePreview() {
  const firstName = cell("Ada");
  const lastName = cell("Lovelace");
  const displayName = computed(() => `${firstName.get()} ${lastName.get()}`);

  return <p>{displayName.get()}</p>;
}

Pass an equality function when equivalent derived results should not notify downstream work.

import { cell, computed } from "@reckona/mreact-reactive-core";

export default function ParityBadge() {
  const count = cell(0);
  const parity = computed(() => count.get() % 2, {
    equals: (previous, next) => previous === next,
  });

  return (
    <button type="button" onClick={() => count.set((value) => value + 1)}>
      {parity.get() === 0 ? "Even" : "Odd"}
    </button>
  );
}

Use selector() when one source value selects exactly one key from a larger keyed set. It returns a keyed boolean reader, so only the previously selected key and the newly selected key notify their subscribers when the source changes.

import { cell, selector } from "@reckona/mreact-reactive-core";

export default function RowSelection() {
  const selectedId = cell<string | null>(null);
  const isSelected = selector<string | null, string>(selectedId);
  const rows = ["alpha", "beta", "gamma"];

  return (
    <ul>
      {rows.map((id) => (
        <li className={isSelected(id) ? "selected" : ""}>
          <button type="button" onClick={() => selectedId.set(id)}>
            {id}
          </button>
        </li>
      ))}
    </ul>
  );
}

Effects

Use effect() for side effects that should re-run after the values they read change. Effects return a dispose function.

import { cell, effect } from "@reckona/mreact-reactive-core";

export default function DebugCounter() {
  const count = cell(0);

  effect(() => {
    console.log("count", count.get());
  });

  return (
    <button type="button" onClick={() => count.set((value) => value + 1)}>
      Count: {count.get()}
    </button>
  );
}

Prefer using JSX for UI updates. Reach for effect() when you need to connect reactive values to logging, imperative integrations, subscriptions, or low-level runtime code. If you create an effect manually outside a component or cleanup owner, keep the dispose function and call it when that owner is torn down.

Native components mounted through JSX use runtime setup tracking as a fallback for reads the compiler cannot turn into bindings. This includes reads hidden inside imported or nested helpers. When one of those sources changes, the surrounding render scope recreates the component so setup decisions and their side effects run again. Sources changed by the same synchronous setup are excluded from this fallback, including computed values that depend on those sources, which prevents a setup read and write from repeatedly triggering itself. A write queued in a microtask or promise runs after synchronous setup and remains reactive. Use untrack() around a guard read when later asynchronous writes must not recreate the component.

Component-body const values derived from cell reads or props remain reactive when used in JSX. This includes chains through helper calls, values passed to child components, and locally selected component tags. The compiler shares helper results through a computed value, so multiple JSX consumers reuse the same result until its dependencies change. Keep derivation helpers free of side effects; use effect() for imperative work. Creating a local cell(props.initialValue) still initializes that state once per mount.

function SelectedPhoto(props) {
  const id = selectedPhotoId.get();
  const item = findPhoto(id);
  const caption = props.caption;
  return <PhotoViewer item={item} caption={caption} />;
}

Changing selectedPhotoId updates the viewer's item without rerunning SelectedPhoto setup. Reading a prop into a const in PhotoViewer also preserves updates in its JSX. Mutable locals and explicitly untracked reads remain snapshots; use computed() when a reusable reactive value is needed outside compiler-managed JSX.

For a resource that must be created once during setup, make that snapshot explicit: const resource = untrack(() => createResource(props.id)). The compiler cannot infer whether an application helper allocates a resource or computes a value; a helper called with reactive inputs must follow the derivation contract unless its read is explicitly untracked.

Use the existing untrack() helper when an imperative operation needs a snapshot inside an otherwise tracked scope:

import { cell, effect, untrack } from "@reckona/mreact-reactive-core";

const query = cell("");
const setupInFlight = cell(false);

const dispose = effect(() => {
  const currentQuery = query.get();
  const busy = untrack(() => setupInFlight.get());
  console.log(currentQuery, busy);
});

// Call dispose() when this manually owned effect is no longer needed.

Here, changing query reruns the effect; changing setupInFlight alone does not.

For DOM-independent code, use createCleanupScope() when one owner creates several resources. Cleanup is idempotent and runs in last-in-first-out order. runWithCleanupScope() binds the existing automatic cleanup registration for the synchronous callback; an await ends that dynamic binding, so resources created afterward should use the explicit scope handle.

import { createCleanupScope, effect, runWithCleanupScope } from "@reckona/mreact-reactive-core";

const scope = createCleanupScope();
runWithCleanupScope(scope, () => {
  effect(() => console.log(count.get()));
});

const response = await fetch("/api/profile");
scope.register(() => response.body?.cancel());
scope.dispose();

Disposing an observer removes that observer's subscriptions and browser listeners. cancelQueries() aborts matching in-flight requests, removeQueries() evicts matching cache entries, and client inactiveGcTime performs eventual cleanup for settled entries without active observers. These operations remain separate so disposing one observer does not cancel a request shared by another observer.

For a continuous external stream, createResource() shares one subscription per active key inside that factory. Each observe() call returns a lease with a reactive pending or ready state. The last lease disconnects, and setKey() disconnects from the old key before subscribing to the new one. A lease created inside a cleanup scope is disposed with that scope; otherwise, call dispose() explicitly.

import { createResource } from "@reckona/mreact-reactive-core";

const messages = createResource<string, string>((channel, emit) => {
  const stream = new EventSource(`/events/${encodeURIComponent(channel)}`);
  stream.addEventListener("message", (event) => emit((event as MessageEvent).data));
  return () => stream.close();
});

const lease = messages.observe("project-42");
const state = lease.state.get();
lease.setKey("project-43");
lease.dispose();

Create browser-only transports when observing on the client. This API keeps the latest value; it does not preserve every event, reconnect automatically, or reconcile an SSR snapshot with a stream cursor. Use a transport-specific adapter when those guarantees matter.

For an event stream where individual events matter, use createEventResource() with an explicit per-observer queue capacity. Each observer drains its own FIFO queue; if it fills, the oldest events are removed and dropped reports the loss. Treat any nonzero loss as a signal to resynchronize when the stream cannot tolerate gaps.

import { createEventResource } from "@reckona/mreact-reactive-core";

const events = createEventResource<string, string>((channel, emit) => {
  const source = new EventSource(`/events/${encodeURIComponent(channel)}`);
  source.addEventListener("message", (event) => emit((event as MessageEvent).data));
  return () => source.close();
}, { capacity: 128 });

const observer = events.observe("project-42");
const { events: messages, dropped } = observer.drain();
observer.dispose();

createEventResource() shares the upstream subscription for observers of the same key and disconnects after the last observer leaves. If a synchronous subscription fails after another observer has joined, that observer's state.get().error exposes the failure and setKey() can retry. It does not implement transport reconnects or SSR cursor reconciliation; an adapter must supply those rules for its service.

Resources that outlive a component evaluation

computed(), effects, query observers, and the computeds inside virtual grids register with the cleanup owner active when they are constructed. A component evaluation can have a shorter lifetime than the component or a controller retained by your application: reevaluation can dispose that evaluation's resources. Keeping an object in a variable does not change its cleanup owner.

Use runDetached() to construct a resource with an explicitly managed lifetime, including lazy construction from a component body:

import { effect, runDetached } from "@reckona/mreact-reactive-core";

const dispose = runDetached(() => effect(() => console.log(count.get())));
// The application owner must call this when the resource is no longer needed.
dispose();

For a controller that owns a query and a virtual grid, create an independent createCleanupScope(), build both inside runWithCleanupScope(controllerScope, ...), and expose controllerScope.dispose() as the controller's disposal method. This also disposes the virtual grid's internal computeds, which have no separate virtualizer disposal method. Creating a cleanup scope does not attach it to the surrounding component evaluation.

runDetached() only removes the implicit cleanup owner during the synchronous callback. It restores the previous owner even if construction throws, preserves dependency tracking, and allows an explicit nested cleanup scope. It does not extend ownership across await, detach DOM nodes or bindings, or make state safe to share between SSR requests. Keep user-specific controllers inside their request or client lifetime; never retain them in server module globals.

DOM bindings

Application code usually uses JSX. The compiler and low-level DOM integrations use @reckona/mreact-reactive-dom bindings such as createRoot(), bindText(), bindProp(), and bindEvent() to connect cells to DOM nodes without rerendering an entire component tree.

import { cell } from "@reckona/mreact-reactive-core";
import { bindText, createRoot } from "@reckona/mreact-reactive-dom";

export function mountCounter(root: HTMLElement) {
  const count = cell(0);

  return createRoot(root, () => {
    const button = document.createElement("button");
    const text = document.createTextNode("");
    button.type = "button";
    button.addEventListener("click", () => count.set((value) => value + 1));
    bindText(text, () => `Count: ${count.get()}`);
    button.append(text);
    return button;
  });
}

Keep the dispose function returned by createRoot() when mounting manually. Bindings created inside that root are cleaned up with the root; bindings created outside a root need their own disposal path.

Use bindList() for low-level list bindings. Keyed lists use reactive object rows by default, so replacing an item with the same key can update field reads inside that row without recreating its DOM. Pass itemMode: "static" when the renderer treats each item as an immutable snapshot and you want append, remove, clear, and reverse paths to preserve keyed DOM identity without per-row object tracking.

Keyed collections for large lists

@reckona/mreact-collection is an opt-in store for keyed rows. Each row has a separate reactive value, while order and count can be read independently. Use it when frequent row edits would otherwise replace a large array and wake consumers that only need one row.

import { createCollection } from "@reckona/mreact-collection";

const projects = createCollection([{ id: "p1", status: "open" }], {
  key: (project) => project.id,
});

projects.patch("p1", { status: "done" });
projects.append({ id: "p2", status: "open" });
projects.move("p2", { before: "p1" });
const firstStatus = projects.row("p1")?.get()?.status;
const total = projects.count.get();

patch() changes one row without changing the order or count cells. move() changes order, while append() and remove() change order and count. toArray() walks every row, so passing it into the current bindList() path still performs a full list read. Filtering and sorting by an edited field may require structural work as well; this package does not automatically incrementalize arbitrary JavaScript callbacks.

Client boundaries

Mreact usually infers client interactivity from code that needs the browser, such as event handlers and reactive state. Keep ordinary interactive components in regular files first; the compiler can still keep the surrounding page server-rendered while hydrating the interactive part that needs the browser.

// src/app/cart/Quantity.tsx
import { cell } from "@reckona/mreact-reactive-core";

export default function Quantity() {
  const quantity = cell(1);

  return (
    <button type="button" onClick={() => quantity.set((value) => value + 1)}>
      Quantity: {quantity.get()}
    </button>
  );
}
// src/app/cart/page.tsx
import Quantity from "./Quantity";

export default function Page() {
  return (
    <main>
      <h1>Cart</h1>
      <Quantity />
    </main>
  );
}

This keeps the page shell server-rendered while the quantity control hydrates in the browser.

Use .client.tsx when you want an explicit client boundary instead of relying on inference. It is useful for documenting a boundary, isolating browser-only integrations, or handling code paths where browser behavior is hidden behind dynamic dispatch and inference cannot see it.

When to use each primitive

  • Use cell() for local mutable state.
  • Use computed() for derived readonly state.
  • Use selector() when one source value selects one key from a larger keyed set.
  • Use effect() for imperative side effects.
  • Use JSX for normal UI binding.
  • Use @reckona/mreact-reactive-dom only for low-level DOM integration or compiler-style output.
  • Rely on client inference for ordinary event handlers and reactive state.
  • Use .client.tsx when you want to make a client boundary explicit.
  • Use route-level "use client" when the whole route is browser-owned.