openapi-ts / openapi-typescript

Generate TypeScript types from OpenAPI 3 specs
https://openapi-ts.dev
MIT License
5.96k stars 474 forks source link

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

Open iffa opened 3 months ago

iffa commented 3 months ago

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.

michalfedyna commented 3 months ago

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

michalfedyna commented 3 months ago

@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.

iffa commented 3 months ago

@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

michalfedyna commented 3 months ago

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

iffa commented 3 months ago

@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

michalfedyna commented 3 months ago

@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.

iffa commented 3 months ago

@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
michalfedyna commented 3 months ago

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

kerwanp commented 3 months ago

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.

michalfedyna commented 3 months ago

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

kerwanp commented 3 months ago

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');
Pagebakers commented 3 months ago

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 commented 3 months ago

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
kerwanp commented 3 months ago

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:

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

zsugabubus commented 3 months ago

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>(...));
Pagebakers commented 2 months ago

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