Skip to main content

@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

NameKindOne-liner
queryOptionsfunctionWrap any compile() + execute() builder for useQuery / useSuspenseQuery / prefetchQuery / queryClient.fetchQuery.
mutationOptionsfunctionWrap a per-input builder factory for useMutation. The factory is called fresh on every invocation.
mutationOptionsBoundfunctionWrap a builder that's already bound (e.g. fhir.delete("Patient", id)); the resulting mutation takes no input.
FhirQueryBuildertypeMinimal structural shape (compile() + execute()) every fhir-dsl terminal builder satisfies.
FhirQueryKeytypeDerived queryKey shape: readonly ["fhir", method, path, sortedParams].
isFhirDslErrorfunctionRe-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, or queryClient.fetchQuery — the return type matches whatever TanStack expects.
  • queryFn is wired with ({ signal }) => builder.execute({ signal }) so useQuery's automatic abort-on-unmount works without extra plumbing.
  • Errors are typed as FhirDslError; pattern-match on result.error.kind instead of parsing .message.
  • overrides is the standard TanStack options object — pass enabled, 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.
  • error flows as FhirDslErroronError(err) and mutation.error are 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-bound update(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 inside onError callbacks if you need to distinguish a FhirDslError from 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.