Skip to content

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.

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.

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.

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 standard fetch handler 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: a carrier for the connector side and a serverEndpoint for 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 inMemoryCarrier out for wsCarrier and the exact same test shape runs over a real socket.

  • createBinaryWireAdapter — the binary format defineChannel builds for you, exposed for custom carriers.

Only reach for these when a single channel can’t describe the routing you need.