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

openapi-react-query: Add easy way of invalidating queries

Open iffa opened this issue 1 year ago • 18 comments
trafficstars

There is currently no easy way to invalidate queries. #1804 and #1805 together would solve this by allowing us to do it ourselves, but a built-in mechanism could also be helpful.

iffa avatar Aug 02 '24 06:08 iffa

@kerwanp I'll work on that if you want, this feature will help me a lot.

michalfedyna avatar Aug 02 '24 09:08 michalfedyna

@iffa after taking closer look, I don't think you'll need ability to inject QueryClient. Methods from openapi-react-query are using QueryClient provided by QueryClientProvider.

import { QueryClientProvider, QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

const QueryProvider  = ({ children }) => {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

export {QueryProvider, queryClient}

Just wrap your app with QueryProvider, and than invalidate using queryClient. It works for me.

michalfedyna avatar Aug 02 '24 09:08 michalfedyna

@iffa after taking closer look, I don't think you'll need ability to inject QueryClient. Methods from openapi-react-query are using QueryClient provided by QueryClientProvider.


import { QueryClientProvider, QueryClient } from "@tanstack/react-query";



const queryClient = new QueryClient();



const QueryProvider  = ({ children }) => {

  return (

    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

  );

};



export {QueryProvider, queryClient}

Just wrap your app with QueryProvider, and than invalidate using queryClient. It works for me.

Ah, nice. Just need an easier way to get the generated query keys and this is a good approach

iffa avatar Aug 02 '24 09:08 iffa

@iffa Could you provide an example of how interface for that should look like?

michalfedyna avatar Aug 02 '24 09:08 michalfedyna

@iffa Could you provide an example of how interface for that should look like?

Not sure if it is an ideal approach but one way would be to make the use* functions return the original result + the generated querykey, like this:

      return {queryKey: [...], ...useQuery({
        queryKey: [method, path, init],
        queryFn: async () => {
          const mth = method.toUpperCase() as keyof typeof client;
          const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
          const { data, error } = await fn(path, init as any); // TODO: find a way to avoid as any
          if (error || !data) {
            throw error;
          }
          return data;
        },
        ...options,
      })};

But one may also want to invalidate based on the first 2 parts of the query key (method, path) so I dunno how that would work

iffa avatar Aug 02 '24 09:08 iffa

@iffa I think it should be done in @tanstack/react-query, not here. When you call useQuery from openapi-react-query you're providing keys there, so you have access to them. You could always run queryClient.clear() to clear all caches or just refetch on particular query. So in my opinion it shouldn't be done like in your example.

michalfedyna avatar Aug 02 '24 10:08 michalfedyna

@iffa I think it should be done in @tanstack/react-query, not here. When you call useQuery from openapi-react-query you're providing keys there, so you have access to them. You could always run queryClient.clear() to clear all caches or just refetch on particular query. So in my opinion it shouldn't be done like in your example.

Maybe something along the lines of this:

export type GetQueryKeyMethod<
  Paths extends Record<string, Record<HttpMethod, {}>>,
> = <
  Method extends HttpMethod,
  Path extends PathsWithMethod<Paths, Method>,
  Init extends FetchOptions<FilterKeys<Paths[Path], Method>> | undefined,
>(
  method: Method,
  url: Path,
  init?: Init,
) => ReadonlyArray<unknown>;

export interface OpenapiQueryClient<
  Paths extends {},
  Media extends MediaType = MediaType,
> {
  useQuery: UseQueryMethod<Paths, Media>;
  getQueryKey: GetQueryKeyMethod<Paths>;
  useSuspenseQuery: UseSuspenseQueryMethod<Paths, Media>;
  useMutation: UseMutationMethod<Paths, Media>;
}

export default function createClient<
  Paths extends {},
  Media extends MediaType = MediaType,
>(client: FetchClient<Paths, Media>): OpenapiQueryClient<Paths, Media> {
  return {
    getQueryKey: (method, path, init) => {
      return [method, path, ...(init ? [init] : [])] as const;
    },
// ... rest

iffa avatar Aug 02 '24 10:08 iffa

I think it looks better just as a helper function. I think you can open PR with that and discuss that with maintainers @drwpow @kerwanp

michalfedyna avatar Aug 02 '24 10:08 michalfedyna

Hey! We clearly have to find a way to give the ability to easily invalidate queries. Unfortunately, simply returning the query keys from the hook is not enough as we might want to invalidate queries outside a component (or a hook).

I think the idea of an helper function works great, your example should work @iffa. I think the function could even be defined outside the createClient so it can be used when generating the real query keys.

An other idea I had in mind was to abstract even more around tanstack:

const usePostsQuery = $api.createQuery('/api/posts/{id}');
const useCreatePostMutation = $api.createMutation('/api/posts/{id}');

export const Example1 = () => {
    const { query } = usePostsQuery();
    const { mutate } = useCreatePostMutation(data, {
        onSuccess: {
            queryClient.invalidateQueries({ queryKey: usePostsQuery.queryKey })
        }
    });
}

export const Example2 = () => {
    const { query } = usePostsQuery();
    const { mutate } = useCreatePostMutation(data, {
        onSuccess: () => {
            usePostsQuery.invalidate()
        }
    });
}

We could then imagine some kind of factory to work with CRUDS:

const $posts = $api.createCrud({
    create: '/api/posts',
    get: '/api/posts/{id}',
    delete: '/api/posts/{id}'
});

export const Example = () => {
    const { query } = $posts.useGet(5);
    const { mutate } = $posts.useCreate();
}

With optimistic updates, automatic invalidation, etc. But this would require to move away from tanstack and be more than just a wrapper. Maybe in a separate library.

kerwanp avatar Aug 02 '24 10:08 kerwanp

In my opinion openapi-react-query should be just thin wrapper around react-query providing type safety.

michalfedyna avatar Aug 02 '24 10:08 michalfedyna

In my opinion openapi-react-query should be just thin wrapper around react-query providing type safety.

I agree with that, so let's go with @iffa example. We just need to remove the context of query as keys can also be used in mutations. More like:

$api.keys('get', '/api/posts');
$api.getKeys('get', '/api/posts');
$api.generateKeys('get', '/api/posts');

kerwanp avatar Aug 02 '24 11:08 kerwanp

React Query has a queryOptions helper ideally we can do the following:

const options = queryOptions('get', '/endpoint', {})

queryClient.invalidateQueries(options)

// mutate cache
queryClient.setQueryData(options, (prevData) => ({
...prevData
})

// and this one is important to be able to fully use useSuspenseQuery
queryClient.prefetchQuery(options)

Pagebakers avatar Aug 13 '24 15:08 Pagebakers

Added this to my project, working nicely.

import {
  queryOptions as tanstackQueryOptions,
  QueryOptions,
  DefinedInitialDataOptions,
} from '@tanstack/react-query'

import type {
  ClientMethod,
  FetchResponse,
  MaybeOptionalInit,
  Client as FetchClient,
} from 'openapi-fetch'
import type {
  HttpMethod,
  MediaType,
  PathsWithMethod,
} from 'openapi-typescript-helpers'

import { getClient } from './api'

type QueryOptionsMethod<
  Paths extends Record<string, Record<HttpMethod, {}>>,
  Media extends MediaType = MediaType,
> = <
  Method extends HttpMethod,
  Path extends PathsWithMethod<Paths, Method>,
  Init extends MaybeOptionalInit<Paths[Path], Method>,
  Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>,
  Options extends Omit<
    QueryOptions<Response['data'], Response['error']>,
    'queryKey' | 'queryFn'
  >,
>(
  method: Method,
  path: Path,
  init?: Init & { [key: string]: unknown },
  options?: Options,
) => DefinedInitialDataOptions<Response['data'], Response['error']>

interface QueryOptionsReturn<
  Paths extends {},
  Media extends MediaType = MediaType,
> {
  queryOptions: QueryOptionsMethod<Paths, Media>
}

const createQueryOptions = <
  Paths extends {},
  Media extends MediaType = MediaType,
>(
  client: FetchClient<Paths, Media>,
): QueryOptionsReturn<Paths, Media> => {
  return {
    queryOptions: (method, path, init, options) => {
      return tanstackQueryOptions({
        queryKey: [method, path, init],
        queryFn: async () => {
          const mth = method.toUpperCase() as keyof typeof client
          const fn = client[mth] as ClientMethod<Paths, typeof method, Media>
          const { data, error } = await fn(path, init as any) // TODO: find a way to avoid as any
          if (error || !data) {
            throw error
          }
          return data
        },
        ...(options as any),
      })
    },
  }
}

export const { queryOptions } = createQueryOptions(getClient())

export type InferQueryData<T> =
  T extends DefinedInitialDataOptions<infer D, any> ? Partial<D> : never

Pagebakers avatar Aug 14 '24 10:08 Pagebakers

React Query has a queryOptions helper ideally we can do the following:

const options = queryOptions('get', '/endpoint', {})

queryClient.invalidateQueries(options)

// mutate cache
queryClient.setQueryData(options, (prevData) => ({
...prevData
})

// and this one is important to be able to fully use useSuspenseQuery
queryClient.prefetchQuery(options)

I like a lot this solution as it follows the api of @tanstack/react-query.

$api.queryOptions('get', '/api/posts');

This should also make the implementation of the following issues much easier and cleaner:

  • https://github.com/openapi-ts/openapi-typescript/issues/1807
  • https://github.com/openapi-ts/openapi-typescript/issues/1828

@zsugabubu the proposed solution is really similar to your PR, do you want to take this issue?

kerwanp avatar Aug 18 '24 22:08 kerwanp

queryOptions would cover exact matching usecase only, that is a serious limitation compared to the API provided by react-query, and it returns query options, not query filters, so theoretically it could not even be used in this context.

What about creating top-level (outside createClient) queryFilters and mutationFilters?

// { queryKey: ['get', '/items/{id}'], stale: true }
queryFilters<Paths>('get', '/items/{id}', undefined, { stale: true }));

// { queryKey: ['get', '/items/{id}', { params: ... }] }
queryFilters<Paths>('get', '/items/{id}', { params: { path: { id: 'foo' } } });

// { queryKey: ['get', '/items/{id}'], predicate: () => ... }
queryFilters<Paths>('get', '/items/{id}', init => init.params.path.id == 'foo');

// And use it like:
queryClient.invalidateQueries(queryFilters<Paths>(...));

zsugabubus avatar Aug 20 '24 02:08 zsugabubus

@zsugabubus the init and options param is optional, so you can also support non exact matching queries.

Pagebakers avatar Sep 11 '24 09:09 Pagebakers

I want to update the cache using setQueryData, my application will be heavely using setQueryData and I'm not sure about the best way to get the keys, is getting them from queryOptions is the recommended way?

evrrnv avatar Jan 27 '25 12:01 evrrnv

That's one of the use cases yes :)

Pagebakers avatar Jan 27 '25 17:01 Pagebakers

@michalfedyna are you still working on this? Happy to try to give a crack at this

chitalian avatar Apr 17 '25 21:04 chitalian

@michalfedyna are you still working on this? Happy to try to give a crack at this

I'm not, I don't remember what it was about.

michalfedyna avatar Apr 18 '25 09:04 michalfedyna

@drwpow I see you self-assigned, is this something that is planned/possible?

RWOverdijk avatar Apr 25 '25 16:04 RWOverdijk

If anyone sees this you can hook into react-query directly and use invalidateQuery normally until this is supported. For example

the query

    const { data: habits } = useSuspenseQuery(
        queryClient.queryOptions('get', '/users/{userId}/habits', {
            params: {
                path: { userId },
            },
            enabled: !!userId,
        })
    );

can be invalidated by queryClientTanstack.invalidateQueries({ queryKey: ['get', '/users/{userId}'] });

athammer avatar May 01 '25 02:05 athammer

In my opinion openapi-react-query should be just thin wrapper around react-query providing type safety.

I agree, but would it be possible to include a thin wrapper over setQueryData that types the update function?

const newPosts = ...;
const newUsers = ...;

$api.setQueryData('get', '/api/posts', {params : ... }, (oldPosts) => newPosts); // ✅

$api.setQueryData('get', '/api/posts', {params : ... }, (oldPosts) => newUsers); // ❌

I am working on this as a helper function, will update here if I can get it to work

ShoeBoom avatar May 07 '25 12:05 ShoeBoom

The query keys that openapi-typescript generates don't seem to be very flexible. I'm trying to figure out how to invalidate some queries and even after putting all the params I passed to the query, I still can't invalidate the query. This is on routes that use path params.

margaretjoanmiller avatar Sep 29 '25 15:09 margaretjoanmiller