openapi-ts icon indicating copy to clipboard operation
openapi-ts copied to clipboard

Custom queryKey generation for @tanstack/react-query plugin

Open SergGrey1992 opened this issue 8 months ago • 15 comments

The current implementation of queryKey generation in the @tanstack/react-query plugin includes the baseUrl in the queryKey:

[ { "_id": "getAllCountry", "baseUrl": "https://api.test.local/nsi" }] This creates significant issues when working with multiple environments. For example:

server: http://localhost:8000/nsi/v1/countries client (brouser): https://api.test.local/nsi/v1/countries

Including the baseUrl in the queryKey means that:

Switching environments creates new cache entries instead of reusing existing ones Testing becomes more difficult since queryKeys differ between environments Manual cache manipulation requires knowing the exact baseUrl for each environment

Current Implementation The current implementation looks like this:

const createQueryKey = <TOptions extends Options>(
  id: string,
  options?: TOptions,
  infinite?: boolean
): [QueryKey<TOptions>[0]] => {
  const params: QueryKey<TOptions>[0] = {
    _id: id,
    baseUrl: (options?.client ?? _heyApiClient).getConfig().baseUrl,
  } as QueryKey<TOptions>[0];
  if (infinite) {
    params._infinite = infinite;
  }
  if (options?.body) {
    params.body = options.body;
  }
  if (options?.headers) {
    params.headers = options.headers;
  }
  if (options?.path) {
    params.path = options.path;
  }
  if (options?.query) {
    params.query = options.query;
  }
  return [params];
};

Feature Request I'd like the ability to customize the queryKey generation, particularly to exclude the baseUrl or modify how it's structured. Proposed Solution Add a queryKeyBuilder option to the @tanstack/react-query plugin config:

export default {
  plugins: [
    {
      name: '@tanstack/react-query',
      queryKeyBuilder: (id, options, infinite) => {
        // Custom implementation to build queryKey that omits baseUrl
        const params = { _id: id };
        
        if (infinite) params._infinite = infinite;
        if (options?.body) params.body = options.body;
        if (options?.path) params.path = options.path;
        if (options?.query) params.query = options.query;
        
        return [params]; // Return without baseUrl
      }
    }
  ]
}

Or a simpler option to just disable baseUrl inclusion:

export default {
  plugins: [
    {
      name: '@tanstack/react-query',
      includeBaseUrlInQueryKey: false // Simple flag to exclude baseUrl
    }
  ]
}

Impact

This enhancement would:

Improve cache consistency across environments

Reduce the size of queryKeys Make manual cache operations more predictable Support more complex caching strategies

Technical Details

The implementation would need to modify the queryKey generation to either:

Use a user-provided function for queryKey generation

Apply a set of configuration options to control what gets included

Workarounds

Until this feature is implemented, users need to either:

Override queryKey for each hook call

Use a global QueryClient with a custom queryKeyFactory Create wrapper hooks to transform the queryKeys

These solutions add extra code and complexity that could be avoided with a proper configuration option.

SergGrey1992 avatar May 14 '25 12:05 SergGrey1992

@mrlubos do you have any idea how to solve this without creating a wrapper hooks? as we are using this for both SSR and Client side, on the server side we have different baseURL while on the client it is going through our ingress. this is causing the queries to be executed twice, once on the server, and again on the client since the keys are different screwing the whole SSR :(

omridevk avatar Aug 11 '25 13:08 omridevk

@omridevk can you explain why you're unable to set the same base url on both? If that's the key issue sounds like we might want to focus on that. Of course another option would be to remove base url from keys entirely..

mrlubos avatar Aug 11 '25 14:08 mrlubos

@mrlubos I would suggest to remove the baseURL all together if we can, not sure if it needed to cache invalidation We are using k8s, and when I do SSR, since the server is inside the k8s cluster, I am using the k8s internal URL to make the API calls (setting the baseURL to an internal URL) On the client however, I cannot go through the internal URL since it is not available, rather i have to go through the ingress, thus having two different base URLs for each step, one for SSR and one for CSR

omridevk avatar Aug 11 '25 16:08 omridevk

I don't want the server to go outside the cluster, doing egresses, this can increase our cost and can break some of our ingress rules

omridevk avatar Aug 11 '25 16:08 omridevk

Sounds like offering an option to not include baseUrl would help your case. I'll need to check why we include it but I'm sure there was a reason, so making it optional should satisfy both requirements

mrlubos avatar Aug 11 '25 16:08 mrlubos

Love it!

omridevk avatar Aug 11 '25 16:08 omridevk

Feature Request: Customizable Query Key Generation in @tanstack/react-query Plugin

Problem

The current implementation of createQueryKey function automatically includes all request parameters in the key, including baseUrl and headers. This creates an issue when using the same request in different contexts (e.g., internal/external client) where these parameters might differ, but we want the query key to remain the same for proper caching.

Current Behavior

Generated code:

const createQueryKey = <TOptions extends Options>(
  id: string,
  options?: TOptions,
  infinite?: boolean
): [QueryKey<TOptions>[0]] => {
  const params: QueryKey<TOptions>[0] = {
    admin: 'admin',
    _id: id,
    baseUrl: (options?.client ?? _heyApiClient).getConfig().baseUrl,
  } as QueryKey<TOptions>[0];
  if (infinite) {
    params._infinite = infinite;
  }
  if (options?.body) {
    params.body = options.body;
  }
  if (options?.headers) {
    params.headers = options.headers;
  }
  if (options?.path) {
    params.path = options.path;
  }
  if (options?.query) {
    params.query = options.query;
  }
  return [params];
};

###My code

//sdk/openapi-configs/_base.ts
const createQueryKey = <TOptions extends Options>(
  id: string,
  options?: TOptions,
  infinite?: boolean
): [QueryKey<TOptions>[0]] => {
  const params: QueryKey<TOptions>[0] = {
    admin: 'admin',
    _id: id,
    // baseUrl: (options?.client ?? _heyApiClient).getConfig().baseUrl,
  } as QueryKey<TOptions>[0];
  if (infinite) {
    params._infinite = infinite;
  }
  if (options?.body) {
    params.body = options.body;
  }
  // if (options?.headers) {
  //   params.headers = options.headers;
  // }
  if (options?.path) {
    params.path = options.path;
  }
  if (options?.query) {
    params.query = options.query;
  }
  return [params];
};
//sdk/openapi-configs/admin.ts
import { defineConfig } from '@hey-api/openapi-ts';

import { getConfig } from './_base';

export default defineConfig(getConfig('admin'));

//package.json
"scripts": {
   ....
   "openapi-ts:admin": "openapi-ts --file openapi-configs/admin.config.ts",
....
}

Current Workaround

We have to manually comment out lines in the generated code after each generation:

// baseUrl: (options?.client ?? _heyApiClient).getConfig().baseUrl,
// if (options?.headers) {
//   params.headers = options.headers;
// }

Proposed Solution

Add a configuration option to the @tanstack/react-query plugin that allows customizing which fields should be included in the query key.

Option 1: Field whitelist

{
  name: '@tanstack/react-query',
  queryKeyFields: ['path', 'query', 'body'] // exclude baseUrl and headers
}

Option 2: Custom function

{
  name: '@tanstack/react-query',
  createQueryKey: (params) => {
    // custom key creation logic
    const { baseUrl, headers, ...keyParams } = params;
    return keyParams;
  }
}

Example Configuration

// sdk/openapi-configs/_base.ts
import { UserConfig } from '@hey-api/openapi-ts';

export const getConfig = (api: string): UserConfig => ({
  input: `./src/swagger-docs/${api}.json`,
  output: {
    indexFile: false,
    format: 'prettier',
    lint: 'eslint',
    path: `./src/openapi-ts/${api}`,
  },
  plugins: [
    '@hey-api/client-next',
    '@hey-api/schemas',
    'zod',
    {
      dates: true,
      name: '@hey-api/transformers',
    },
    {
      enums: 'javascript',
      name: '@hey-api/typescript',
    },
    {
      name: '@hey-api/sdk',
    },
    {
      name: '@tanstack/react-query',
      queryKeyFields: ['path', 'query', 'body'] // <-- Proposed new option
    }
  ],
});

Benefits

  • No manual editing: Eliminates the need to manually edit generated code after each generation
  • Flexibility: Provides control over cache management for different use cases
  • Backward compatibility: Default behavior can include all fields to maintain compatibility
  • Type safety: Configuration can be type-checked at compile time

Use Case

In our project, we use the same API endpoints with different clients (internal/external), and they have different base URLs and headers. However, we want them to share the same cache because the data is identical. Currently, this is impossible without manually modifying the generated code.

SergGrey1992 avatar Aug 11 '25 16:08 SergGrey1992

@SergGrey1992 can you update the original description? It's your issue! 😀

mrlubos avatar Aug 11 '25 16:08 mrlubos

Also used this workaround for now:

import {hashKey, QueryClient} from '@tanstack/react-query'

new QueryClient({
    defaultOptions: {
      queries: {
        queryKeyHashFn: (args) => {
          const key = args.map(({baseUrl, ...key}) => ({
            ...key,
          }))
          return hashKey(key)
        },
      },
    },
  })

FYI @SergGrey1992 if you want, you can use this workaround until the fix is merged

omridevk avatar Aug 12 '25 09:08 omridevk

@mrlubos unfortunately this workaround doesn't really work as expected, the client is still being hydrated with the wrong baseURL causing the refetch to use the queryFn defined on the server :(

do you think a fix will be merged any time soon? allowing us to remove the baseURL as queryKey all together?

omridevk avatar Aug 14 '25 07:08 omridevk

https://github.com/hey-api/openapi-ts/pull/1709 I see this PR this may help no?

omridevk avatar Aug 14 '25 07:08 omridevk

@omridevk do you want to open a pull request adding a baseUrl option to queryKeys and infiniteQueryKeys? It would work the same way as tags, except we'd want it enabled by default to preserve the current functionality https://heyapi.dev/openapi-ts/plugins/tanstack-query#tags

mrlubos avatar Aug 14 '25 10:08 mrlubos

Custom query key is a whole another beast. Adding the baseUrl option is the most straightforward fix for your issue

mrlubos avatar Aug 14 '25 10:08 mrlubos

I have another use-case that I think would have been more easily solved with the custom key function.

The default query keys use a single composite object with baseUrl, _id (operationId), and all input parameters (query, path, body, headers) mashed into one object. However, tanstack query keys are also able to work hierarchically, allowing matching/filtering multiple queries via prefix elements at the start of the array. In my case, I would love to have been able to match against all queries in the cache that have the same operationId, regardless of the other parameters. If the keys had been set up in a hierarchy like [ baseUrl, id, params ] then it would be trivial to match and invalidate or refresh all queries with the same operation id, or even all queries from the same server (baseUrl).

As it is, I've worked around this by using a custom predicate to match the queries against the _id field. No further action needed for my case, but I'm posting this in case anyone else runs into something similar.

pmarkert avatar Oct 22 '25 00:10 pmarkert

I have the same issue as original poster. I need separate baseUrls because my React Router 7 server loader hits the direct API URL (api.myapp.com) while the browser hits a BFF proxy route (webapp.myapp.com/api) that parses auth cookies into bearer token and forwards to main api.

If I edit the generated file to delete the baseUrl out of the createQueryKey function, I get the desired behaviour — the server state matches the client state and no reload is attempted.

My workaround for now is to add this patch command to package.json:

{
  "codegen:patch": "sed -i '' 's/, baseUrl: options\\?\\.baseUrl || (options\\?\\.client ?? _heyApiClient)\\.getConfig()\\.baseUrl//' ../src/api-client/generated/react-query/@tanstack/react-query.gen.ts"
}

paulswail avatar Oct 24 '25 22:10 paulswail