Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/query-key-fn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"openapi-react-query": minor
---

Add `queryKeyFn` option to `createClient` for per-client query key namespacing.

When two `openapi-fetch` clients target different servers but share endpoint paths,
TanStack Query deduplicates their requests because the default `[method, path, init]`
key is identical. Passing a `queryKeyFn` lets callers namespace keys per client
instance so each client maintains an independent cache.
31 changes: 31 additions & 0 deletions packages/openapi-react-query/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,37 @@ const MyComponent = () => {

> You can find more information about `createFetchClient` in the [openapi-fetch documentation](../openapi-fetch).

## Options

### `queryKeyFn`

TanStack Query deduplicates cache entries by query key. By default, `openapi-react-query`
builds keys as `[method, path, init]`. If you use two separate `openapi-fetch` clients
that target different servers but share endpoint paths, their keys will collide — only
one request will fire and both hooks will receive the same cached data.

Pass a `queryKeyFn` to `createClient` to namespace keys per client instance:

```ts
import createFetchClient from "openapi-fetch";
import createClient from "openapi-react-query";
import type { paths } from "./my-openapi-3-schema";

const appClient = createClient(
createFetchClient<paths>({ baseUrl: "https://app-api.example.com" }),
);

const pdmClient = createClient(
createFetchClient<paths>({ baseUrl: "https://pdm-api.example.com" }),
{
queryKeyFn: (method, path, init) => ["pdm", method, path, init],
},
);
```

Each client now maintains its own cache namespace, so identical paths on different
servers are fetched and stored independently.

## 📓 Docs

[View Docs](https://openapi-ts.dev/openapi-react-query/)
48 changes: 26 additions & 22 deletions packages/openapi-react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
type DataTag,
type InfiniteData,
type QueryClient,
type QueryFunctionContext,
type SkipToken,
type UseInfiniteQueryOptions,
type UseInfiniteQueryResult,
Expand Down Expand Up @@ -38,6 +37,12 @@ export type QueryKey<
Init = MaybeOptionalInit<Paths[Path], Method>,
> = Init extends undefined ? readonly [Method, Path] : readonly [Method, Path, Init];

export type QueryKeyFn = (method: HttpMethod, path: string, init: unknown) => readonly unknown[];

export interface CreateClientOptions {
queryKeyFn?: QueryKeyFn;
}

export type QueryOptionsFunction<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Expand Down Expand Up @@ -192,32 +197,31 @@ export type MethodResponse<
// TODO: Add the ability to bring queryClient as argument
export default function createClient<Paths extends {}, Media extends MediaType = MediaType>(
client: FetchClient<Paths, Media>,
clientOptions?: CreateClientOptions,
): OpenapiQueryClient<Paths, Media> {
const queryFn = async <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>>({
queryKey: [method, path, init],
signal,
}: QueryFunctionContext<QueryKey<Paths, Method, Path>>) => {
const mth = method.toUpperCase() as Uppercase<typeof method>;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
const { data, error, response } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any
if (error) {
throw error;
}
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
return data ?? null;
}
const buildQueryKey: QueryKeyFn =
clientOptions?.queryKeyFn ??
((method, path, init) => (init === undefined ? ([method, path] as const) : ([method, path, init] as const)));

return data;
};

const queryOptions: QueryOptionsFunction<Paths, Media> = (method, path, ...[init, options]) => ({
queryKey: (init === undefined ? ([method, path] as const) : ([method, path, init] as const)) as DataTag<
const queryOptions: QueryOptionsFunction<Paths, Media> = (method, path, ...[init, queryOpts]) => ({
queryKey: buildQueryKey(method, path as string, init) as DataTag<
QueryKey<Paths, typeof method, typeof path>,
any,
any
>,
queryFn,
...options,
queryFn: async ({ signal }) => {
const mth = method.toUpperCase() as Uppercase<typeof method>;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
const { data, error, response } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any
if (error) {
throw error;
}
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
return data ?? null;
}
return data;
},
...queryOpts,
});

return {
Expand All @@ -232,7 +236,7 @@ export default function createClient<Paths extends {}, Media extends MediaType =
return useInfiniteQuery(
{
queryKey,
queryFn: async ({ queryKey: [method, path, init], pageParam = 0, signal }) => {
queryFn: async ({ pageParam = 0, signal }) => {
const mth = method.toUpperCase() as Uppercase<typeof method>;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
const mergedInit = {
Expand Down
92 changes: 92 additions & 0 deletions packages/openapi-react-query/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,98 @@ describe("client", () => {
});
});

describe("queryKeyFn", () => {
it("default key is [method, path] when no init is provided", () => {
const fetchClient = createFetchClient<minimalGetPaths>({ baseUrl });
const client = createClient(fetchClient);

const { queryKey } = client.queryOptions("get", "/foo");
expect(queryKey).toEqual(["get", "/foo"]);
});

it("default key is [method, path, init] when init is provided", () => {
const fetchClient = createFetchClient<minimalGetPaths>({ baseUrl });
const client = createClient(fetchClient);

const { queryKey } = client.queryOptions("get", "/foo", {});
expect(queryKey).toEqual(["get", "/foo", {}]);
});

it("uses custom queryKeyFn when provided", () => {
const fetchClient = createFetchClient<minimalGetPaths>({ baseUrl });
const client = createClient(fetchClient, {
queryKeyFn: (method, path, init) => ["ns", method, path, init],
});

const { queryKey } = client.queryOptions("get", "/foo");
expect(queryKey).toEqual(["ns", "get", "/foo", undefined]);
});

it("custom queryKeyFn prevents deduplication across clients with different namespaces", async () => {
const response = ["one", "two", "three"];

const fetchClient = createFetchClient<paths>({ baseUrl });
const client1 = createClient(fetchClient, {
queryKeyFn: (method, path, init) => ["ns1", method, path, init],
});
const client2 = createClient(fetchClient, {
queryKeyFn: (method, path, init) => ["ns2", method, path, init],
});

useMockRequestHandler({
baseUrl,
method: "get",
path: "/string-array",
status: 200,
body: response,
});

const { result: result1 } = renderHook(() => client1.useQuery("get", "/string-array"), { wrapper });
const { result: result2 } = renderHook(() => client2.useQuery("get", "/string-array"), { wrapper });

await waitFor(() => {
expect(result1.current.isFetching).toBe(false);
expect(result2.current.isFetching).toBe(false);
});

// Both clients should have data resolved independently
expect(result1.current.data).toEqual(response);
expect(result2.current.data).toEqual(response);

// Two separate cache entries should exist — one per namespace
const cacheKeys = queryClient
.getQueryCache()
.getAll()
.map((q) => q.queryKey);
expect(cacheKeys.some((k) => k[0] === "ns1")).toBe(true);
expect(cacheKeys.some((k) => k[0] === "ns2")).toBe(true);
});

it("custom queryKeyFn receives data via useQuery", async () => {
const response = ["one", "two", "three"];

const fetchClient = createFetchClient<paths>({ baseUrl });
const client = createClient(fetchClient, {
queryKeyFn: (method, path, init) => ["custom", method, path, init],
});

useMockRequestHandler({
baseUrl,
method: "get",
path: "/string-array",
status: 200,
body: response,
});

const { result } = renderHook(() => client.useQuery("get", "/string-array"), { wrapper });

await waitFor(() => expect(result.current.isFetching).toBe(false));

expect(result.current.data).toEqual(response);
expect(result.current.error).toBeNull();
});
});

describe("useQuery", () => {
it("should resolve data properly and have error as null when successful request", async () => {
const response = ["one", "two", "three"];
Expand Down
Loading