React Query & Building Blocks
TanStack Query hooks for your actions, a one-line devtools pointer, and the lower-level pieces connectChannel is built from — for the rare routing a single channel can't describe.
React Query integration
Section titled “React Query integration”Import from @nice-code/action/react-query (peer dep: @tanstack/react-query).
import { useActionQuery, useActionMutation } from "@nice-code/action/react-query";
function UserProfile({ userId }: { userId: string }) { const { data } = useActionQuery( act_user.action.getUser, { userId }, { queryKey: ["user", userId] }, ); return <div>{data?.name}</div>;}
function RenameUser() { const { mutate } = useActionMutation(act_user.action.updateName); return <button onClick={() => mutate({ userId: "u_1", name: "Bob" })}>Rename</button>;}The input, output, and errors all come straight from the action schema — you don’t write a query function or any types by hand.
Devtools
Section titled “Devtools”Actions surface in the devtools window with one call — createNiceDevtools({ runtime, domains: [act_app] }) names the root domains to observe (an action whose root isn’t listed never surfaces), and on the server createNiceServerDevtools({ runtime, domains: [act_app] }) does the same for a backend. Failed runs are sorted by the same question you check in code (see Error Handling): an error is labelled Expected (declared) or Unexpected (undeclared / unhandled) based on result.expected. The whole window, the server host, and its options are documented in the Devtools section; Observing Backends covers attaching a server or a Durable Object.
Lower-level building blocks
Section titled “Lower-level building blocks”connectChannel / serveChannel are the entry points you should normally use. For the rare case where your routing isn’t a single channel, the pieces they’re built from are also exported — most under the @nice-code/action/advanced subpath:
-
acceptChannel/acceptChannelConnections— build a secure listening side by hand, with access to each connection. -
createActionFetchHandler— the standardfetchhandler on its own. -
createInMemoryChannelPair/inMemoryCarrier— connect two runtimes in the same process (for tests or same-process peers) with no network involved.inMemoryCarrier()hands back two cross-wired ends: acarrierfor the connector side and aserverEndpointfor the acceptor side. Frames cross on a microtask, so each side observes the other asynchronously — exactly like a real socket, but with no socket to spin up. That lets a unit test exercise your full channel, handlers, routing, and types end to end:import { describe, it, expect } from "vitest";import { ActionRuntime, connectChannel, acceptChannelConnections, inMemoryCarrier } from "@nice-code/action";import { createSecureChannelAcceptor } from "@nice-code/action/advanced";import { StorageAdapter, createMemoryStorageMethods_json } from "@nice-code/util";import { appChannel, act_user, serverCoord, frontendCoord } from "./shared";// A throwaway in-memory crypto-identity store, fresh per test.const memStorage = () => new StorageAdapter({ methods: createMemoryStorageMethods_json(new Map()) });describe("getUser", () => {it("routes a call through the channel with full types, no sockets", async () => {const { carrier, serverEndpoint } = inMemoryCarrier();// Acceptor: wire the in-memory end into an acceptor, register your cases.const serverRuntime = new ActionRuntime(serverCoord);const conn = { id: "test-conn" };const acceptor = createSecureChannelAcceptor<typeof conn>({channel: appChannel,runtime: serverRuntime,storage: memStorage(),send: (_c, frame) => serverEndpoint.send(frame),});serverRuntime.addHandlers([acceptChannelConnections(acceptor, appChannel, {getUser: ({ input }) => ({ id: input.userId, name: "Test User" }),}),acceptor,]);serverEndpoint.onMessage((frame) => acceptor.receive(conn, frame));// Connector: dial the in-memory carrier instead of a real WebSocket.const clientRuntime = new ActionRuntime(frontendCoord);connectChannel(clientRuntime, appChannel, {peer: serverCoord,storage: memStorage(),transports: [{ carrier }],});const user = await act_user.action.getUser.request({ userId: "u_1" }).runToOutput();expect(user).toEqual({ id: "u_1", name: "Test User" }); // typed both sides});});You’re mocking the edge network topology locally with full type safety — flip
inMemoryCarrierout forwsCarrierand the exact same test shape runs over a real socket. -
createBinaryWireAdapter— the binary formatdefineChannelbuilds for you, exposed for custom carriers.
Only reach for these when a single channel can’t describe the routing you need.