fabien0102 / openapi-codegen

A tool for generating code base on an OpenAPI schema.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Invalidate generated React Query

kallebornemark opened this issue · comments

I want to invalidate the query in useMyQuery when the call in useMyMutation succeeds. To do this, I need to provide the query key from the former to the onSuccess callback on the latter.

const { data } = useMyQuery(); // generated hook

const { mutateAsync } = useMyMutation({ // generated hook
  onSuccess: () => {
    queryClient.invalidateQueries(
      // query key from useMyQuery - how?
    );
  },
});

So, as I see it, I either need to:

  1. Be able to provide a custom query key when using useMyQuery
  2. Get access to useMyQuery's generated query key
  3. Manually provide the query key to useMyMutation's onSuccess as it's predictable (but I'd rather not do this)

What's the best way to achieve this?

Not pretty, but I ended up doing something similar to what @fabien0102 talked about in #147 (comment) by adding a $queryKey property to the return data for all types.

function generateConfig(filenamePrefix: string, url: string): Config {
  return {
    // ...
    to: async (context) => {
      Object.entries(context.openAPIDocument.components!.schemas!).forEach(
        ([_, value]) => {
          if ("properties" in value) {
            value.properties!.$queryKey = {
              type: "array",
            };
          }
        }
      );
    },
  };
}

This adds the property to all of the generated return types. Then in the fetcher file I can calculate the query key using the url and the pathParams.

const generateQueryKeyFromUrl = (
  url: string,
  pathParams: Record<string, string | number> | undefined
) =>
  url
    .split("/")
    .map((part) => {
      if (!pathParams || !part.startsWith("{") || !part.endsWith("}")) {
        return part;
      }
      const queryParamWithoutDelimiters = part.substring(1, part.length - 1);
      const interpolatedQueryParam = pathParams[queryParamWithoutDelimiters];
      return interpolatedQueryParam ?? "";
    })
    .slice(1);

and add it to the return data

if (response.headers.get("content-type")?.includes("json")) {
  const json = await response.json();
  const $queryKey = generateQueryKeyFromUrl(url, pathParams);
  return {
    ...json,
    $queryKey,
  }
}

which finally lets me access it in the consumer component

const { data } = useFoo();

const { mutateAsync } = useUpdateFoo({
  onSuccess: (newData) => {
    if (data?.$queryKey) {
      queryClient.setQueryData(data.$queryKey, () => newData);
    }
  },
});

Hey, in the generated {namespace}Context.ts, you have this function:

// {namespace}Context.ts
  export const queryKeyFn = (operation: QueryOperation) => {
    const queryKey: unknown[] = hasPathParams(operation)
      ? operation.path
          .split("/")
          .filter(Boolean)
          .map((i) => resolvePathParam(i, operation.variables.pathParams))
      : operation.path.split("/").filter(Boolean);
  
    if (hasQueryParams(operation)) {
      queryKey.push(operation.variables.queryParams);
    }
  
    if (hasBody(operation)) {
      queryKey.push(operation.variables.body);
    }
  
    return queryKey;
  }

This function is used by every generated useQuery hooks, so normally, you can just do this:

import { queryKeyFn } from "./myAppContext"

const { data } = useMyQuery(); // generated hook

const { mutateAsync } = useMyMutation({ // generated hook
  onSuccess: () => {
    queryClient.invalidateQueries(
      queryKeyFn({ path: "/something", operationId: "something", variables: {} }); // Typescript should help you here
    );
  },
});

You can also use the devtool (https://tanstack.com/query/v3/docs/react/devtools) to check the key and see if everything works as expected

This is actually something I would love to change (with a breaking change) to mimic how trpc is doing it, they are using a useContext() with all the proper helpers, and I love it! But time…

Anyway, this should fit your needs for now 😃

Ah, that's pretty sweet. Noticed that the type of the object parameter expected by queryKeyFn is a union of all the endpoints, so IntelliSense is nice. Aaand I don't have to pollute the types with a property that's only going to be used like 2% of the time. 😁

Thanks!

This works, but I'm curious. Would I be able to expose the internally generated query key to the return value of the generated hooks? Basically what I want is this:

const { data, queryKey } = useMyQuery(); // generated

I'm sorry, this can't work the way you want since the queryKey depends on the query params. useMyQuery is just a proxy to useQuery and queryKey is not part of the API.

I guess you could wrap the useMyQuery() if you really want this API, but be careful with query params.

That sounds pretty easy to me, internally you seem to generate this:

export const useGetUser = <TData = Schemas.User>(
  variables: GetUserVariables,
  options?: Omit<
    reactQuery.UseQueryOptions<Schemas.User, GetUserError, TData>,
    'queryKey' | 'queryFn' | 'initialData'
  >
) => {
  const {fetcherOptions, queryOptions, queryKeyFn} = useApiContext(options);
  return reactQuery.useQuery<Schemas.User, GetUserError, TData>({
    queryKey: queryKeyFn({
      path: '/user/{userId}',
      operationId: 'getUser',
      variables,
    }),
    queryFn: ({signal}) => fetchGetUser({...fetcherOptions, ...variables}, signal),
    ...options,
    ...queryOptions,
  });
};

You could just expose another function that only computes the key:

export const useGetUserKey = (
  variables: GetUserVariables,
  options?: Omit<
    reactQuery.UseQueryOptions<Schemas.User, GetUserError, TData>,
    'queryKey' | 'queryFn' | 'initialData'
  >
) => {
  const {queryKeyFn} = useApiContext(options);
  return queryKeyFn({
      path: '/user/{userId}',
      operationId: 'getUser',
      variables,
  });
};

Which can also be used in the useGetUser to avoid duplication:

export const useGetUser = <TData = Schemas.User>(
  variables: GetUserVariables,
  options?: Omit<
    reactQuery.UseQueryOptions<Schemas.User, GetUserError, TData>,
    'queryKey' | 'queryFn' | 'initialData'
  >
) => {
  const {fetcherOptions, queryOptions} = useApiContext(options);
  const queryKey = useGetUserKey(variables, options);
  return reactQuery.useQuery<Schemas.User, GetUserError, TData>({
    queryKey,
    queryFn: ({signal}) => fetchGetUser({...fetcherOptions, ...variables}, signal),
    ...options,
    ...queryOptions,
  });
};

That only expose the key value to the outside world, but doesn't change the internals (well it does call useApiContext twice, but it shouldn't make any difference).

Btw @fabien0102, I don't thni the solution you proposed is ideal:

import { queryKeyFn } from "./myAppContext"

const { data } = useMyQuery(); // generated hook

const { mutateAsync } = useMyMutation({ // generated hook
  onSuccess: () => {
    queryClient.invalidateQueries(
      queryKeyFn({ path: "/something", operationId: "something", variables: {} }); // Typescript should help you here
    );
  },
});

Because here, we (as end users) don't know the endpoint URL (its not exported either). Therefore, with this solution you have a high risk of not updating your (backend) code the day you change the endpoint's path from /something to /something-else.

On the other hand, if the generated code was simply exposing the path, it would solve this issue too, with the getUser example, that would give:

export const GET_USER_OPERATION = {
    path: '/user/{userId}',
    operationId: getUser'
}

export const useGetUser = <TData = Schemas.User>(
  variables: GetUserVariables,
  options?: Omit<
    reactQuery.UseQueryOptions<Schemas.User, GetUserError, TData>,
    'queryKey' | 'queryFn' | 'initialData'
  >
) => {
  const {fetcherOptions, queryOptions, queryKeyFn} = useApiContext(options);
  return reactQuery.useQuery<Schemas.User, GetUserError, TData>({
    queryKey: queryKeyFn({
      path: GET_USER_OPERATION.path,
      operationId: GET_USER_OPERATION.operationId,
      variables,
    }),
    queryFn: ({signal}) => fetchGetUser({...fetcherOptions, ...variables}, signal),
    ...options,
    ...queryOptions,
  });
};

Or directly:

queryKeyFn({
  ...GET_USER_OPERATION,
  variables,
})