The TypeScript packages mirror the backend connection model:
@musubi/clientowns Phoenix channel interaction, patch application, streams, async result normalization, command dispatch, and store proxies.@musubi/reactprovides React context and hooks over anMusubiConnection.
Plain TypeScript
Create one Phoenix socket, then call connect<Musubi.Stores>(socket) once.
The generic argument binds the generated registry for the connection;
every later mountStore call infers the store type from the module
string literal alone.
import { Socket } from "phoenix"
import { connect } from "@musubi/client"
const socket = new Socket("/socket", {
params: { token: window.userToken },
})
const connection = await connect<Musubi.Stores>(socket)Mount a declared root store. mountStore returns a
{ store, unmount } pair:
const { store: dashboard, unmount } = await connection.mountStore({
module: "MyApp.Stores.DashboardStore",
id: "dashboard",
})Read state through the proxy:
dashboard.header.title
dashboard.polls.map((poll) => poll.title)Dispatch a declared command:
await dashboard.dispatchCommand("refresh", {})Unmount when the root is no longer needed by calling the unmount
closure returned from mountStore:
await unmount()React: createMusubi
@musubi/react exports a createMusubi<R>() factory. Call it exactly
once per app, alongside the Phoenix socket. The factory closes over the
generated registry R and returns a connect, MusubiProvider, and
hook set whose closures all know R without any further generic
threading:
// src/musubi.ts
import { Socket } from "phoenix"
import { createMusubi } from "@musubi/react"
export const socket = new Socket("/socket", {
params: { token: window.userToken },
})
export const {
connect,
MusubiProvider,
useMusubiConnection,
useMusubiConnectionStatus,
useMusubiRoot,
useMusubiRootSuspense,
useMusubiSnapshot,
useMusubiCommand,
} = createMusubi<Musubi.Stores>()MusubiProvider accepts either a pre-opened connection or a raw
socket. The two props are mutually exclusive (enforced at the type
level and at runtime).
Variant A — open the connection at app boot, pass connection:
// src/main.tsx
import { createRoot } from "react-dom/client"
import App from "./App"
import { connect, MusubiProvider, socket } from "./musubi"
const root = createRoot(document.getElementById("root")!)
const connection = await connect(socket)
root.render(
<MusubiProvider connection={connection}>
<App />
</MusubiProvider>,
)Variant B — pass socket; the provider opens the connection itself and
exposes its lifecycle through useMusubiConnectionStatus():
import { MusubiProvider, socket } from "./musubi"
root.render(
<MusubiProvider socket={socket}>
<App />
</MusubiProvider>,
)Observe Connection Status
useMusubiConnectionStatus() returns
{ state: "connecting" | "ready" | "error", connection, error? }. Use
it to render a fallback while the socket-prop provider is connecting, or
to surface a connect failure:
import { useMusubiConnectionStatus } from "./musubi"
function AppShell({ children }: { children: React.ReactNode }) {
const status = useMusubiConnectionStatus()
if (status.state === "connecting") return <Spinner />
if (status.state === "error") return <p>Connect failed: {status.error.message}</p>
return <>{children}</>
}useMusubiConnection() returns the live MusubiConnection<R> once
ready and throws if called before ready — use useMusubiConnectionStatus()
when the calling component must tolerate the pre-ready states.
Mount Roots In React
Use the factory's useMusubiRoot to mount a declared root under the
nearest provider. The module literal alone drives inference:
import { useMusubiRoot } from "./musubi"
export function Dashboard() {
const root = useMusubiRoot({
module: "MyApp.Stores.DashboardStore",
id: "dashboard",
})
if (root.status === "loading") {
return null
}
if (root.status === "error") {
return <p>{root.error.message}</p>
}
return <DashboardContent store={root.store} />
}useMusubiRoot unmounts the root when the component unmounts by default. Pass
unmountOnCleanup: false when a mounted root should outlive the component.
Mount cache keying is canonical: params are stringified with sorted
keys, so { a: 1, b: 2 } and { b: 2, a: 1 } resolve to the same
mounted root. Two components requesting the same {module, id, params}
share one server-side mount and ref-count its lifetime.
Suspense Variant
useMusubiRootSuspense(options) returns the StoreProxy directly. It
throws the in-flight Promise for <Suspense> and a cached Error for
the nearest error boundary, and shares the same root-mount cache as
useMusubiRoot (Suspense-safe orphan cleanup on abort):
import { Suspense } from "react"
import { ErrorBoundary } from "react-error-boundary"
import { useMusubiRootSuspense } from "./musubi"
function Dashboard() {
const store = useMusubiRootSuspense({
module: "MyApp.Stores.DashboardStore",
id: "dashboard",
})
return <DashboardContent store={store} />
}
export function DashboardPage() {
return (
<ErrorBoundary fallback={<p>Failed to load dashboard</p>}>
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
)
}Subscribe To Snapshots
useMusubiSnapshot subscribes to proxy updates and returns an immutable
snapshot:
import type { StoreProxy } from "@musubi/react"
import { keyOf } from "@musubi/react"
import { useMusubiCommand, useMusubiSnapshot } from "./musubi"
type Store<M extends keyof Musubi.Stores & string> = StoreProxy<M, Musubi.Stores>
function DashboardContent({ store }: { store: Store<"MyApp.Stores.DashboardStore"> }) {
const title = useMusubiSnapshot(store, (snapshot) => snapshot.header.title)
const { dispatch: refresh, isPending, error } = useMusubiCommand(store, "refresh")
return (
<>
<button disabled={isPending} onClick={() => void refresh({})}>
{title}
</button>
{error && <p role="alert">{error.code ?? error.message}</p>}
</>
)
}Use selectors to keep React renders focused. A component that selects
snapshot.header.title does not need to re-render for unrelated store fields.
When a selector is supplied, useMusubiSnapshot defaults equalityFn
to shallowEqual, so selectors that return an object/tuple of fields
do not cause spurious re-renders when their elements are referentially
equal. Pass an explicit equalityFn to override.
Commands And Structured Errors
useMusubiCommand(proxy, name) returns a mutation-shaped result:
interface MusubiCommandResult<M, K, R> {
dispatch: (payload) => Promise<Reply>
isPending: boolean
error: MusubiCommandError | null
data: Reply | null
reset: () => void
}Concurrent dispatch calls are sequenced by a monotonic request token —
only the latest call's outcome lands in data / error. Call reset()
to clear data / error and return to the idle state.
Both dispatch rejection and the hook's error field carry a
MusubiCommandError (re-exported from @musubi/client):
import { MusubiCommandError } from "@musubi/client"
class MusubiCommandError extends Error {
kind: "failed" | "timeout" // server reply error vs. dispatch timeout
command: string // command name
storeId: readonly string[] // target store path
reply: unknown // raw server reply (failed kind only)
code: string | undefined // extracted from reply.code/error/reason
}MusubiCommandError.is(value) is a cross-module-safe type guard (uses
name rather than instanceof, so it works across bundle boundaries).
Use error.code for routing to user-visible copy; fall back to
error.message for unstructured cases. dispatchConnectionCommand from
@musubi/client throws the same class for direct-proxy callers.
const { dispatch, error } = useMusubiCommand(cart, "checkout")
async function onSubmit() {
try {
await dispatch({})
} catch (e) {
if (MusubiCommandError.is(e) && e.kind === "timeout") {
toast("Took too long — try again")
}
}
}Target Child Stores
Every store in the tree — not just the root — can declare commands and accept
them through its own proxy. Children show up as nested proxies on the root,
reachable by field access for single children and by array index for list
children. useMusubiCommand works on any proxy.
Declaring a command on a child store
The Elixir DSL is identical for root and child stores:
defmodule MyApp.Stores.CartLineStore do
use Musubi.Store
attr(:line, map(), required: true)
attr(:on_qty_change, (String.t(), integer() -> :ok), required: true)
state do
field(:qty, integer())
end
command :inc_qty do
reply do
field :qty, integer()
end
end
@impl Musubi.Store
def handle_command(:inc_qty, _payload, socket) do
next_qty = socket.assigns.qty + 1
socket = assign(socket, :qty, next_qty)
:ok = socket.assigns.on_qty_change.(socket.assigns.id, next_qty)
{:reply, %{"qty" => next_qty}, socket}
end
endReaching the child proxy from React
Field access returns a single child proxy; iterating a list field returns one
proxy per element. Each carries its own dispatchCommand:
function HeaderRename({ root }: { root: Store<"MyApp.Stores.CartPageStore"> }) {
const { dispatch: setName } = useMusubiCommand(root.header, "rename")
return <button onClick={() => void setName({ name: "Ada" })}>Rename</button>
}
function CartLine({ lineProxy }: { lineProxy: Store<"MyApp.Stores.CartLineStore"> }) {
const line = useMusubiSnapshot(lineProxy)
const { dispatch: inc } = useMusubiCommand(lineProxy, "inc_qty")
return <button onClick={() => void inc({})}>{line.qty}</button>
}
function CartLines({ root }: { root: Store<"MyApp.Stores.CartPageStore"> }) {
return (
<ul>
{root.cart.lines.map((lineProxy) => (
<CartLine key={keyOf(lineProxy)} lineProxy={lineProxy} />
))}
</ul>
)
}keyOf(proxy) returns a stable string derived from the proxy's
store_id path. Use it as a React list key; do not read
__musubi_store_id__ directly. keyOf is exported from @musubi/client
and re-exported by @musubi/react.
The wire payload carries the full store_id path, so the page server routes
inc_qty directly to the addressed CartLineStore instance. Authorization
and audit hooks attached to ancestor stores fire as part of that chain.
Notify the parent from a child command
Children don't write to a parent's socket.assigns directly. The conventional
pattern is a function-valued attr (BDR-0010) — the parent supplies a closure
in its render/1, and the child invokes it after mutating its own state:
# Parent
def render(socket) do
%{
lines:
for line <- socket.assigns.lines do
child(CartLineStore,
id: line.id,
line: line,
on_qty_change: socket.assigns.on_qty_change # stable closure built in mount/1
)
end
}
endWhat the closure does is up to the parent — write through a shared
Persistence snapshot whose PubSub broadcast re-flows the new state through
the root, send(self(), {:msg, ...}) so the root's handle_info/2 picks it
up, or hit any other application boundary. Building the closure once in
mount/1 (rather than per-render) keeps the function reference stable across
re-renders so child memoization via __changed__ (BDR-0013) still applies.
See examples/cart_page for a runnable demo of the full child-command +
callback-attr round-trip.
Async Results And Streams
The client normalizes Musubi.AsyncResult values to:
type AsyncResult<T> =
| { status: "loading"; data: T | null; error: null }
| { status: "ok"; data: T; error: null }
| { status: "failed"; data: T | null; error: unknown }Streams are materialized as arrays. The server sends stream ops; the client owns list materialization and limit trimming.