@fhir-dsl/tanstack-query
Overview
@fhir-dsl/tanstack-query is a thin wrapper that turns any fhir-dsl terminal builder into a TanStack Query options object. You write useQuery(queryOptions(fhir.read("Patient", id))) and get back a fully typed result.data: Patient | undefined, result.error: FhirDslError | null, plus an automatically derived queryKey that's stable across calls. Mutations get the same treatment via mutationOptions(factory).
The package wraps TanStack's official queryOptions() / mutationOptions() helpers directly — your useQuery / useSuspenseQuery / useMutation consume the result with no special typing.
Installation
npm install @fhir-dsl/tanstack-query @tanstack/react-query
@tanstack/react-query (and its @tanstack/query-core peer) is an optional peer dependency — install it alongside this package.
Quick start
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mutationOptions, queryOptions } from "@fhir-dsl/tanstack-query";
import { createClient } from "./fhir/r4"; // generated by fhir-gen
const fhir = createClient({ baseUrl: "https://hapi.fhir.org/baseR4" });
function PatientCard({ id }: { id: string }) {
const result = useQuery(queryOptions(fhir.read("Patient", id)));
// result.data: Patient | undefined
// result.error: FhirDslError | null ← discriminated by `kind`
const queryClient = useQueryClient();
const update = useMutation(
mutationOptions((p: Patient) => fhir.update(p), {
onSuccess: (next) => {
queryClient.setQueryData(
["fhir", "GET", `Patient/${next.id}`],
next,
);
},
}),
);
if (result.isLoading) return <Skeleton />;
if (result.error) return <ErrorBlock kind={result.error.kind} />;
return <PatientView patient={result.data} onSave={(p) => update.mutate(p)} />;
}
Exports
| Name | Kind | One-liner |
|---|---|---|
queryOptions | function | Wrap any compile() + execute() builder for useQuery / useSuspenseQuery / prefetchQuery / queryClient.fetchQuery. |
mutationOptions | function | Wrap a per-input builder factory for useMutation. The factory is called fresh on every invocation. |
mutationOptionsBound | function | Wrap a builder that's already bound (e.g. fhir.delete("Patient", id)); the resulting mutation takes no input. |
FhirQueryBuilder | type | Minimal structural shape (compile() + execute()) every fhir-dsl terminal builder satisfies. |
FhirQueryKey | type | Derived queryKey shape: readonly ["fhir", method, path, sortedParams]. |
isFhirDslError | function | Re-exported from @fhir-dsl/utils so consumers don't need a second import for the standard catch pattern. |
API
queryOptions
Signature
function queryOptions<T>(
builder: FhirQueryBuilder<T>,
overrides?: Omit<UndefinedInitialDataOptions<T, FhirDslError, T, FhirQueryKey>, "queryKey" | "queryFn">,
): UndefinedInitialDataOptions<T, FhirDslError, T, FhirQueryKey>;
Notes
- Drop the result straight into
useQuery,useSuspenseQuery,prefetchQuery, orqueryClient.fetchQuery— the return type matches whatever TanStack expects. queryFnis wired with({ signal }) => builder.execute({ signal })souseQuery's automatic abort-on-unmount works without extra plumbing.- Errors are typed as
FhirDslError; pattern-match onresult.error.kindinstead of parsing.message. overridesis the standard TanStack options object — passenabled,staleTime,gcTime,refetchOnWindowFocus,select, etc.
Example — server-side _filter round-trip
import { fhirpath } from "@fhir-dsl/fhirpath";
const officialFamilyIsSmith = fhirpath<"Patient">("Patient")
.name.where(($) => $.use.eq("official"))
.family.eq("Smith");
const result = useQuery(
queryOptions(
fhir.search("Patient").filter(officialFamilyIsSmith).count(20),
{ staleTime: 30_000 },
),
);
mutationOptions
Signature
function mutationOptions<TInput, TOutput>(
factory: (input: TInput) => FhirQueryBuilder<TOutput>,
overrides?: Omit<UseMutationOptions<TOutput, FhirDslError, TInput>, "mutationFn">,
): UseMutationOptions<TOutput, FhirDslError, TInput>;
Notes
- The factory is called on every
mutation.mutate(input)so the same options object can be reused across renders. errorflows asFhirDslError—onError(err)andmutation.errorare both narrowed.- Fits the standard optimistic-update + rollback pattern via
onMutate/onError/onSettled.
Example — optimistic update
const queryClient = useQueryClient();
const update = useMutation(
mutationOptions((p: Patient) => fhir.update(p), {
onMutate: async (next) => {
await queryClient.cancelQueries({ queryKey: ["fhir", "GET", `Patient/${next.id}`] });
const previous = queryClient.getQueryData<Patient>(["fhir", "GET", `Patient/${next.id}`]);
queryClient.setQueryData(["fhir", "GET", `Patient/${next.id}`], next);
return { previous };
},
onError: (err, next, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(["fhir", "GET", `Patient/${next.id}`], ctx.previous);
}
if (err.kind === "core.request") toast.error(`HTTP ${err.context.status}`);
},
onSettled: (_data, _err, next) => {
void queryClient.invalidateQueries({ queryKey: ["fhir", "GET", `Patient/${next.id}`] });
},
}),
);
mutationOptionsBound
Signature
function mutationOptionsBound<TOutput>(
builder: FhirQueryBuilder<TOutput>,
overrides?: Omit<UseMutationOptions<TOutput, FhirDslError, void>, "mutationFn">,
): UseMutationOptions<TOutput, FhirDslError, void>;
Notes
- Use when the builder is already fully specified at construction time and the mutation needs no per-call argument — typically
delete(rt, id)or a pre-boundupdate(resource)where the resource is in scope. - The resulting mutation is no-arg:
mutation.mutate().
Example
const remove = useMutation(
mutationOptionsBound(fhir.delete("Patient", "123"), {
onSuccess: () => navigate("/patients"),
}),
);
<Button onClick={() => remove.mutate()}>Delete</Button>
isFhirDslError
Signature
function isFhirDslError(value: unknown): value is FhirDslError;
Notes
- Re-exported from
@fhir-dsl/utils. Useful insideonErrorcallbacks if you need to distinguish aFhirDslErrorfrom an unexpected throw before reading.kind.
queryKey derivation
The derived key is structurally stable and JSON-comparable — same compiled query, same key. It always begins with "fhir" so consumers can scope invalidation:
queryClient.invalidateQueries({ queryKey: ["fhir"] }); // every fhir-dsl query
queryClient.invalidateQueries({ queryKey: ["fhir", "GET", "Patient/123"] }); // one resource
The shape:
type FhirQueryKey = readonly [
"fhir",
string, // method (GET / POST / PUT / …)
string, // path ("Patient/123", "Observation", …)
ReadonlyArray<readonly [name: string, value: string | number, prefix?: string, modifier?: string]>,
];
Params are sorted by name so query order doesn't break cache reuse — where("name", "eq", "Smith").where("active", "eq", "true") and where("active", "eq", "true").where("name", "eq", "Smith") hash to the same key.
Error channel
Both helpers type error as FhirDslError. Pattern-match on kind, read structured context — never parse .message:
const result = useQuery(queryOptions(fhir.read("Patient", id)));
if (result.error) {
switch (result.error.kind) {
case "core.request":
console.error(result.error.context.status, result.error.context.statusText);
break;
case "smart.auth":
navigate("/");
break;
case "core.validation":
// result.error.context.issues is a typed array
break;
}
}
The full table of kinds lives in the error-handling guide.
Related
@fhir-dsl/core— typed builders and the underlyingcompile()/execute()contract.@fhir-dsl/utils—FhirDslError,Result<T, E>,tryAsync,match.- Error handling guide — full
kindtable and the catch-vs-Result patterns. - FHIRPath + Query Builder guide — the
_filter/ projection / aggregate patterns this package composes with.