React applications

Use React for a region with its own interactive state, such as a filterable catalog or editor. Use a Markdown component when reusable markup and slots are enough. PathMX keeps the surrounding content in Sources.

Install

For a custom React integration:

bun add @pathmx/react@beta react@^19 react-dom@^19
bun add --dev @types/react @types/react-dom

Use the package root for PathMX context and Action hooks:

import {
  PathMXProvider,
  useAction,
  useActions,
  useRelations,
  useSource,
} from "@pathmx/react"

Reusable visual components live in the separate @pathmx/ui package so this binding package remains presentation-free.

This package requires React 19 or newer.

Mount a Source-backed app

A component returns { html, clientData } and declares its client with moduleRef. Core updates the host's attributes while preserving React's children. reactClient keeps the root alive through updates and unmounts it when the host leaves.

Create plugins/catalog.plugin.ts:

import { defineComponent, moduleRef } from "@pathmx/core"

export default defineComponent({
  tag: "x-catalog-app",
  async load({ page, view }) {
    const sources = await view.query("sources", { from: "/books" })
    return {
      source: page.id,
      books: sources.map(source => ({ source: source.id, href: source.href, title: source.meta.title })),
    }
  },
  render: moduleRef(() => import("./catalog.render.ts")),
  client: moduleRef(() => import("./catalog.client.tsx")),
})

Create plugins/catalog.render.ts:

import { html } from "@pathmx/core"

export type CatalogData = {
  source: string
  books: { source: string; href: string; title: string }[]
}

export default function render({ data }: { data: CatalogData }) {
  return {
    html: html`<section>
      <ul>${data.books.map(book => html`<li><a href="${book.href}">${book.title}</a></li>`)}</ul>
    </section>`,
    clientData: data,
  }
}

Query inside load so reads use the current View and register live dependencies. The portable renderer receives only the selected JSON data. React receives fresh data when PathMX updates that host; initial links remain useful without JavaScript. The client and its initial styles load only when the component renders.

Add the catalog client

Create plugins/catalog.client.tsx:

import { useState } from "react"
import { reactClient } from "@pathmx/react"
import type { CatalogData } from "./catalog.render"

function CatalogView({ books }: CatalogData) {
  const [search, setSearch] = useState("")
  const visible = books.filter((book) =>
    book.title.toLowerCase().includes(search.trim().toLowerCase()),
  )
  return <>
    <label>Find a book <input type="search" value={search}
      onChange={(event) => setSearch(event.currentTarget.value)} /></label>
    <ul>{visible.map((book) =>
      <li key={book.source}><a href={book.href}>{book.title}</a></li>,
    )}</ul>
    {!visible.length && <p>No matching books.</p>}
  </>
}

export default reactClient((data: CatalogData) =>
  <CatalogView key={data.source} {...data} />,
)

The type-only import shares the projection without adding runtime imports. reactClient supplies the selected client data as React props and owns the shared browser lifecycle.

The key gives this catalog's filter state to its Source. PathMX can retain a matching app host when navigating between Sources, so new data alone does not reset React state. The same Source keeps its filter through live updates; a different Source gets a fresh filter. For an editor or activity, key by the identity that owns its state, including the Actor or target when relevant. An existing persistence key can serve this purpose. Keep the key stable when only the activity's content changes.

Each mounted root receives a unique prefix for React's useId(), keeping label and ARIA references distinct when several apps share a document.

Supply the catalog content

Use the component in paths/index.md:

# Books

<x-catalog-app />

Add paths/books/looking-slowly.book.md:

# Looking Slowly

A guide to observation.

Verify filtering and updates

Run bun run pmx dev, enter part of the title, then edit the book's heading. The displayed title updates while the filter stays in place. Navigate to the book and back to verify mounting and cleanup. The filter is component-local state; removing its host or changing its key resets it. Import a stylesheet from the client when the app needs custom presentation; PathMX bundles that CSS with it.

Use the project TypeScript setup and check types, the rendered page, and the browser console. Lint and export alone do not execute browser interactions. bun run pmx export --origin https://example.com produces a static catalog with browser filtering; later Source edits require a new export. Device-local persistence is a separate application choice.

Invoke a described Action

For saved work, first complete the Input reflection example, including its Actor/Home setup. The following React component submits to that same response. Its field is named idea and its response id is reflection.

The component below is an alternative Save control inside an already mounted React app. The Input example is the complete runnable form. Pass the active page's admitted SourceDescription into PathMXProvider; a description copied from another page does not describe this response's Actions. The catalog above supplies complete app mounting; this section documents the hook contract for apps that already own a description.

import type { SourceDescription } from "@pathmx/core"
import { PathMXProvider, useAction } from "@pathmx/react"

function SaveResponse() {
  const { action, invoke } = useAction("input.submit")
  return (
    <button
      disabled={action?.status !== "available"}
      onClick={async () => {
        await invoke({
          response: "reflection",
          values: { idea: "I can explain the next step." },
        })
      }}
    >
      Save reflection
    </button>
  )
}

export function ReflectionApp({
  description,
}: {
  description: SourceDescription
}) {
  return (
    <PathMXProvider description={description}>
      <SaveResponse />
    </PathMXProvider>
  )
}

Invocation uses the active ViewConnection installed by the page's runtime. The same helper works with HTTP and worker hosts; it does not create another transport. Input accepts nested JSON values. The Action's boundInput supplies defaults; explicitly supplied fields override that described snapshot in the request. The host resolves admission again and reapplies its current authoritative bound context before validation, so a stale client cannot override it.

The returned promise resolves to an ActionReceipt. An unavailable Action rejects before invocation; host failures retain their ActionFailure outcome. React and native Action forms use the same browser execution and document reconciliation. One confirmed execution dispatches one pmx:action completion event. A failed refresh does not undo the saved Action and uses the runtime's normal delayed-update recovery; navigation can supersede applying an older refresh without canceling the mutation. invokePathMXAction(action, input) provides the same behavior outside a provider.

Ordinary PathMX pages install their HTTP connection automatically. Custom worker hosts install theirs with startViewRuntime. Invoking before a host runtime is installed rejects; static publications do not implicitly gain a mutation connection.

Editing an Input field

Declare input: true and a required string name prop on the component definition. The authored name must refer to a field already declared in the owning Input Block. Core passes that field to reactClient; inside the editor, useInputField() uses its assigned scope. Removing the component revokes its write capability.

For an editor spanning several fields, declare their prop keys with input: ["name", "mark"]. In its custom mount, obtain inputFor(element) from @pathmx/core/input/browser, pass that capability as PathMXProvider's input property and select a declared field with useInputField(name). The reactClient single-field shortcut still uses input: true. A Source description alone does not grant a field binding; see multi-field editors.

import { useInputField } from "@pathmx/react"

function Quantity() {
  const { value, editable, set, issues } = useInputField()
  return (
    <label>
      Quantity
      <input
        value={String(value ?? "")}
        disabled={!editable}
        onChange={event => {
          const text = event.currentTarget.value
          set(text === "" ? undefined : Number.isFinite(Number(text)) ? Number(text) : text)
        }}
      />
      <span role="status">{issues.join(" ")}</span>
    </label>
  )
}

The hook subscribes to the shared response owner. set changes a draft; an Input Submit control saves the response. Unmounting the React view does not discard that draft. Incomplete numeric text such as "-" remains editable and produces a validation issue rather than silently restoring an older valid number. undefined clears a field.

The returned snapshot contains value, committed, dirty, editable, issues, pending, uncertain and conflict, plus the stable set function. Pending saves can coexist with further edits. A revoked scope becomes non-editable and rejects writes. The hook requires a declared component binding; it cannot create arbitrary fields or reach other Sources by name.