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.

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.

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.

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.