Capxul Docs
Guides

Drop to the raw client

Call core client methods that don't have first-class hooks yet — useCapxulClientOrNull, gating on bootstrap, unwrapping CapxulResult, and keying under the capxul namespace.

The hooks cover most of the client surface, but not all of it. When a method exists on CapxulClient without a matching hook, you can bind it to TanStack Query yourself in a few lines — the same pattern every first-class hook uses internally.

Before you start

When not to do this

If a first-class hook exists, use it. The hooks are not just bindings — they carry the cache contract: documented query keys, automatic invalidation on money and auth mutations, and telemetry on failures. A hand-rolled useQuery over client.org(orgId).members() works, but it will not be invalidated when an invite mutation succeeds, because the SDK invalidates its key, not yours. Check the hooks reference and the client reference first; drop down only for the gap.

The recipe

Four rules make a raw-client query behave like a first-class hook:

  1. Get the client with useCapxulClientOrNull. It returns null while CapxulProvider is still bootstrapping instead of throwing.
  2. Gate the query with enabled: client !== null (plus any parameter gates), so it sits in isPending until the client resolves.
  3. Unwrap the CapxulResult: if (!result.ok) throw result.error; — the thrown CapxulError lands in the query's error field with the same type every other hook uses.
  4. Key under ["capxul", …]. The provider treats the "capxul" prefix as client-scoped: when you bring your own QueryClient, re-bootstrap removes exactly those keys. Keys outside the namespace would survive a client swap and leak the previous session's data. Pick a segment the SDK catalog does not already use.

Worked example: org profile

client.org(orgId).profile.get() returns the org's public profile (display name, handle) — a read with no first-class hook today. The binding below is exactly how the SDK's own hooks are built, applied to a method that does not have one yet:

"use client";

import { useQuery } from "@tanstack/react-query";
import { useCapxulClientOrNull } from "@capxul/sdk-react";
import type { OrgId } from "@capxul/sdk";

export function useOrgProfile(orgId: OrgId | undefined) {
  const client = useCapxulClientOrNull();

  return useQuery({
    // "profile" is not a segment the SDK uses under ["capxul", "org", orgId].
    queryKey: ["capxul", "org", orgId ?? "pending", "profile"],
    queryFn: async () => {
      const result = await client!.org(orgId!).profile.get();
      if (!result.ok) throw result.error;
      return result.value;
    },
    enabled: client !== null && orgId !== undefined,
  });
}

Consuming it looks exactly like any SDK hook, including the typed error:

function OrgProfileHeader({ orgId }: { readonly orgId: OrgId }) {
  const profile = useOrgProfile(orgId);

  if (profile.isLoading) return <p>Loading…</p>;
  if (profile.isError) return <p>{profile.error.message}</p>;

  return (
    <h2>
      {profile.data.displayName ?? "Unnamed org"}
      {profile.data.handle ? <span> · @{profile.data.handle}</span> : null}
    </h2>
  );
}

The non-null assertions are safe because enabled guarantees the query function never runs while client or orgId is missing — the same invariant the built-in hooks rely on.

Note the "pending" placeholder while orgId is undefined: the query is disabled in that state, so nothing fetches under the placeholder key. This mirrors the SDK's own parameterized keys.

Mutations and invalidation

The same unwrap works in a useMutation, and since your query lives in the shared cache, invalidate it yourself on success:

"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCapxulClientOrNull } from "@capxul/sdk-react";
import type { OrgId } from "@capxul/sdk";

export function useDeployOrgRolesRaw(orgId: OrgId | undefined) {
  const client = useCapxulClientOrNull();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async () => {
      if (client === null || orgId === undefined) {
        throw new Error("client not ready");
      }
      const result = await client.org(orgId).deployRoles();
      if (!result.ok) throw result.error;
      return result.value;
    },
    onSuccess: async () => {
      await queryClient.invalidateQueries({
        queryKey: ["capxul", "org", orgId, "roles"],
      });
    },
  });
}

(This particular verb has a first-class hook — useCapxulOrgDeployRoles — shown here only because a mutation example needs a real method. The recipe is what transfers.)

Cancellation

Every client method accepts an optional { signal } as its last argument. TanStack passes an AbortSignal to query functions; thread it through so unmounts abort in-flight reads:

queryFn: async ({ signal }) => {
  const result = await client!.org(orgId!).profile.get({ signal });
  if (!result.ok) throw result.error;
  return result.value;
},

An aborted call resolves as { ok: false } with a CANCELLED error rather than hanging.

What you end up with

  • A custom hook over any client method, indistinguishable from a first-class hook to its consumers: pending until bootstrap, CapxulError in error, cleaned up on re-bootstrap.
  • A clear rule for when to delete it: as soon as the first-class hook ships, switch and pick up its cache contract for free.

On this page