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
- You know the hook catalog (hooks reference) — this guide is for methods that are not on it.
- You have read Handle errors for the
CapxulResultshape, and TanStack Query integration for the key namespace.
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:
- Get the client with
useCapxulClientOrNull. It returnsnullwhileCapxulProvideris still bootstrapping instead of throwing. - Gate the query with
enabled: client !== null(plus any parameter gates), so it sits inisPendinguntil the client resolves. - Unwrap the
CapxulResult:if (!result.ok) throw result.error;— the thrownCapxulErrorlands in the query'serrorfield with the same type every other hook uses. - 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,
CapxulErrorinerror, 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.