React Query & Building Blocks
TanStack Query hooks for your actions, a pointer to devtools, 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 }, { staleTime: 30_000 }); 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 from the action schema, so you don’t write a query function or any types by hand.
The query key is derived — don’t pass one
Section titled “The query key is derived — don’t pass one”useActionQuery builds the key itself from the action’s identity and input, so queryKey and
queryFn are omitted from its options type. The third argument is for the rest of TanStack’s
options (staleTime, enabled, select, …). The derived key exists to prevent the problems of
hand-rolled ones: two actions sharing ["user", id] collide, and nothing can invalidate
“everything from this action”.
Use actionQueryKey wherever you need that key yourself, such as invalidating after a mutation,
seeding the cache, or reading it:
import { actionQueryKey } from "@nice-code/action/react-query";import { useQueryClient } from "@tanstack/react-query";
const queryClient = useQueryClient();
// One user's entry — the exact key useActionQuery used.queryClient.invalidateQueries({ queryKey: actionQueryKey(act_user.action.getUser, { userId }) });
// Every entry for that action, whatever the input: omit the input and it's a key PREFIX.queryClient.invalidateQueries({ queryKey: actionQueryKey(act_user.action.getUser) });The key is ["nice-action", domain, allDomains, actionId, input?]. It’s namespaced, so it can never
collide with your own keys or another action’s.
Devtools
Section titled “Devtools”One call puts your actions in the devtools window. createNiceDevtools({ runtime, domains: [act_app] }) names the root domains to observe, and an action whose root isn’t listed never appears. 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 Error (declared) or Unexpected Error (undeclared / unhandled) based on result.expected. The Devtools section documents the whole window, the server host, and its options. Observing Backends covers attaching a server or a Durable Object.
Caller stacks are optional because constructing them on every dispatch has a measurable cost. Enable them where the caller location is useful:
const runtime = new ActionRuntime(frontendCoord, { captureCallSites: true });
useActionQuery(act_user.action.getUser, { userId }, { captureCallSites: true });useActionMutation(act_user.action.updateName, { captureCallSites: true });The default is false, including in development. A per-call
request.run({ captureCallSites: true }) overrides the runtime policy. React hooks need their own
opt-in to capture the component or event-handler frame before TanStack Query schedules the action.
Attaching devtools later cannot recover stacks for calls that ran while capture was disabled.
This setting changes diagnostic metadata only; thrown errors keep their normal stacks.
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 of them 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. -
createTestChannelfrom@nice-code/action/testing— run typed cases through the real channel codec, routing, and asynchronous in-memory carrier. Calldispose()in teardown; it closes both runtimes, the link, and the server deterministically:import { describe, it, expect } from "vitest";import { createTestChannel } from "@nice-code/action/testing";import { appChannel, act_user } from "./shared";describe("getUser", () => {it("routes a call through the channel with full types, no sockets", async () => {const test = createTestChannel(appChannel, {getUser: ({ input }, ctx) => {expect(ctx.connection.id).toBe("test-client");return { id: input.userId, name: "Test User" };},});const user = await test.run(act_user.action.getUser.request({ userId: "u_1" }));expect(user).toEqual({ id: "u_1", name: "Test User" }); // typed both sidestest.dispose();});});test.trafficexposes frame counts when a test needs to prove the call crossed the wire. -
inMemoryCarrier— the lower-level loopback carrier used bycreateTestChannel, available when a test needs to assemble a custom host. -
createBinaryWireAdapter— the binary formatdefineChannelbuilds for you, exported for custom carriers.
Only reach for these when a single channel can’t describe the routing you need.