Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
RTK Query cache utils. Useful abstractions for creating `provides`/`invalidates` cache data tags against endpoints.
import { FetchBaseQueryError } from '@rtk-incubator/rtk-query/dist';
/**
* Default tags used by the cacher helpers
*/
const defaultTags = ["UNAUTHORIZED", "UNKNOWN_ERROR"] as const;
type DefaultTags = typeof defaultTags[number];
function concatErrorCache<T, ID>(
existingCache: CacheList<T, ID>,
error: FetchBaseQueryError | undefined
): CacheList<T, ID> {
if (error && "status" in error && error.status === 401) {
// unauthorized error
return [...existingCache, "UNAUTHORIZED"];
}
// unknown error
return [...existingCache, "UNKNOWN_ERROR"];
}
/**
* An individual cache item
*/
export type CacheItem<T, ID> = { type: T; id: ID };
/**
* A list of cache items, including a LIST entity cache
*/
export type CacheList<T, ID> = (
| CacheItem<T, "LIST">
| CacheItem<T, ID>
| DefaultTags
)[];
/**
* Inner function returned by `providesList` to be passed to the `provides` property of a query
*/
type InnerProvidesList<T> = <
Results extends { id: unknown }[],
Error extends FetchBaseQueryError
>(
results: Results | undefined,
error: Error | undefined
) => CacheList<T, Results[number]["id"]>;
/**
* HOF to create an entity cache to provide a LIST,
* depending on the results being in a common format.
*
* Will not provide individual items without a result.
*
* @example
* ```ts
* const results = [
* { id: 1, message: 'foo' },
* { id: 2, message: 'bar' }
* ]
* providesList('Todo')(results)
* // [
* // { type: 'Todo', id: 'List'},
* // { type: 'Todo', id: 1 },
* // { type: 'Todo', id: 2 },
* // ]
* ```
*/
export const providesList = <T extends string>(
type: T
): InnerProvidesList<T> => (results, error) => {
// is result available?
if (results) {
// successful query
return [
{ type, id: "LIST" },
...results.map(({ id }) => ({ type, id } as const))
];
}
// Received an error, include an error cache item to the cache list
return concatErrorCache([{ type, id: "LIST" }], error);
};
/**
* HOF to create an entity cache to invalidate a LIST.
*
* Invalidates regardless of result.
*
* @example
* ```ts
* invalidatesList('Todo')()
* // [{ type: 'Todo', id: 'List' }]
* ```
*/
export const invalidatesList = <T extends string>(type: T) => (): readonly [
CacheItem<T, "LIST">
] => [{ type, id: "LIST" }] as const;
type InnerProvidesNestedList<T> = <
Results extends { data: { id: unknown }[] },
Error extends FetchBaseQueryError
>(
results: Results | undefined,
error: Error | undefined
) => CacheList<T, Results["data"][number]["id"]>;
/**
* Similar to `providesList`, but for data located at a nested property,
* e.g. `results.data` in a paginated response.
* The property is hard coded, so re-create a version of this function based
* on a data shape your API returns for best results.
*/
export const providesNestedList = <T extends string>(
type: T
): InnerProvidesNestedList<T> => (results, error) => {
// is result available?
if (results) {
// successful query
return [
{ type, id: "LIST" },
...results.data.map(({ id }) => ({ type, id } as const))
];
}
// Received an error, include an error cache item to the cache list
return concatErrorCache([{ type, id: "LIST" }], error);
};
/**
* HOF to create an entity cache for a single item using the query argument as the ID.
*
* @example
* ```ts
* cacheByIdArg('Todo')({ id: 5, message: 'walk the fish' }, undefined, 5)
* // returns:
* // [{ type: 'Todo', id: 5 }]
* ```
*/
export const cacheByIdArg = <T extends string>(type: T) => <
ID,
Result = undefined,
Error = undefined
>(
result: Result,
error: Error,
id: ID
): readonly [CacheItem<T, ID>] => [{ type, id }] as const;
/**
* HOF to create an entity cache for a single item using the id property from the query argument as the ID.
*
* @example
* ```ts
* cacheByIdArgProperty('Todo')(undefined, { id: 5, message: 'sweep up' })
* // returns:
* // [{ type: 'Todo', id: 5 }]
* ```
*/
export const cacheByIdArgProperty = <T extends string>(type: T) => <
Arg extends { id: unknown },
Result = undefined,
Error = undefined
>(
result: Result,
error: Error,
arg: Arg
): readonly [CacheItem<T, Arg["id"]>] | [] => [{ type, id: arg.id }] as const;
/**
* HOF to invalidate the 'UNAUTHORIZED' type cache item.
*/
export const invalidatesUnauthorized = () => <
Arg = undefined,
Result = undefined,
Error = undefined
>(
result: Result,
error: Error,
arg: Arg
): ["UNAUTHORIZED"] => ["UNAUTHORIZED"];
/**
* HOF to invalidate the 'UNKNOWN_ERROR' type cache item.
*/
export const invalidatesUnknownErrors = () => <
Arg = undefined,
Result = undefined,
Error = undefined
>(
result: Result,
error: Error,
arg: Arg
): ["UNKNOWN_ERROR"] => ["UNKNOWN_ERROR"];
/**
* Utility helpers for common provides/invalidates scenarios
*/
export const cacher = {
defaultTags,
providesList,
invalidatesList,
providesNestedList,
cacheByIdArg,
cacheByIdArgProperty,
invalidatesUnauthorized,
invalidatesUnknownErrors
};
@Shrugsy

This comment has been minimized.

Copy link
Owner Author

@Shrugsy Shrugsy commented Apr 11, 2021

Example usage:

+  import { cacher } from "./rtkQueryCacheUtils";

  export type Todo = {
    id: number;
    userName: string;
    message: string;
  };

  type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;

  export const todosApi = createApi({
    reducerPath: "todos",
    baseQuery: fetchBaseQuery({
      baseUrl: "/api/"
    }),
+   tagTypes: [...cacher.defaultTags, "Todo"],
-   tagTypes: ["Todo", "UNAUTHORIZED", "UNKNOWN_ERROR"],
    endpoints: (builder) => ({
      login: builder.mutation<unknown, void>({
        query: () => '/login',
+       invalidatesTags: cacher.invalidatesUnauthorized()
-       invalidatesTags: ['UNAUTHORIZED']
      }),
      refetchErroredQueries: builder.mutation<unknown, void>({
        queryFn() {
          return { data: {} };
        },
+       invalidatesTags: cacher.invalidatesUnknownErrors()
-       invalidatesTags: ['UNKNOWN_ERROR']
      }),
      getTodos: builder.query<Todo[], void>({
        query: () => "todos",
+       providesTags: cacher.providesList("Todo")
-        providesTags: (todos) =>
-          todos
-            ? [
-                ...todos.map((todo) => ({ type: "Todo", id: todo.id } as const)),
-                { type: "Todo", id: "LIST" }
-              ]
-              : error && "status" in error && error.status === 401
-            ? [{ type: "Todo", id: "LIST" }, "UNAUTHORIZED"]
-              : [{ type: "Todo", id: "LIST" }, "UNKNOWN_ERROR" }];
      }),
      addTodo: builder.mutation<Todo, Partial<Todo>>({
        query: (body) => ({
          url: "todos",
          method: "POST",
          body
        }),
+       invalidatesTags: cacher.invalidatesList("Todo")
-       invalidatesTags: [{ type: "Todo", id: "LIST" }]
      }),
      updateTodo: builder.mutation<Todo, AtLeast<Todo, "id">>({
        query: (body) => ({
          url: "todos",
          method: "POST",
          body
        }),
+       invalidatesTags: cacher.cacheByIdArgProperty("Todo")
-       invalidatesTags: (result, error, body) => [{ type: "Todo", id: body.id }]
      }),
      deleteTodo: builder.mutation<Todo[], number>({
        query: (body) => ({
          url: `todos/${body}`,
          method: "DELETE"
        }),
+       invalidatesTags: cacher.cacheByIdArg("Todo")
-       invalidatesTags: (result, error, body) => [{ type: "Todo", id: body }]
      })
    })
  });
@Shrugsy

This comment has been minimized.

@troypoulter

This comment has been minimized.

Copy link

@troypoulter troypoulter commented Sep 16, 2021

Just stumbled across this and it's absolutely awesome! Very helpful to use with RTK Query 🔥

I am trying to use this for a paginated API where the response is a single object, containing pagination details and then the results available inside of it.

Have been trying to modify this util to work for it but am getting a bit stuck and wanted to reach out for your thoughts! I'm thinking it has to do with it expecting the result to be an Array but am frankly still learning a lot of this and imagine others may have a similar question in the future.

Here is the type I'm using (which is also in the code below):

  export type PaginatedResponse<T> = {
      totalCount: number;
      totalPage: number;
      currentPage: number;
      data: T[];
  }

Here is the error which originates in this section, specfically the providesTags: cacher.providesList("Todo") line:

      getTodos: builder.query<PaginatedResponse<Todo>, Options>({
        query: ({id, page = 0}) => `organisations/${id}/systems?page=${page - 1}&pageSize=10`,
       providesTags: cacher.providesList("Todo")
      }),
Type 'InnerProvidesList<"Todo">' is not assignable to type 'ResultDescription<"UNAUTHORIZED" | "UNKNOWN_ERROR" | "Todo", PaginatedResponse<Todo>, Options, FetchBaseQueryError>'.
  Type 'InnerProvidesList<"Todo">' is not assignable to type 'GetResultDescriptionFn<"UNAUTHORIZED" | "UNKNOWN_ERROR" | "Todo", PaginatedResponse<Todo>, Options, FetchBaseQueryError>'.
    Types of parameters 'results' and 'result' are incompatible.
      Type 'PaginatedResponse<Todo>' is missing the following properties from type '{ id: unknown; }[]': length, pop, push, concat, and 28 more.

Then here is is a modification to your example above to include the PaginatedResponse type:

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
import { cacher } from "./rtkQueryCacheUtils";
 
  export type PaginatedResponse<T> = {
      totalCount: number;
      totalPage: number;
      currentPage: number;
      data: T[];
  }
 
  export type Todo = {
    id: number;
    userName: string;
    message: string;
  };
 
  interface Options {
    id: number
    page: number,
}
 
  type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
 
  export const todosApi = createApi({
    reducerPath: "todos",
    baseQuery: fetchBaseQuery({
      baseUrl: "/api/"
    }),
   tagTypes: [...cacher.defaultTags, "Todo"],
    endpoints: (builder) => ({
      login: builder.mutation<unknown, void>({
        query: () => '/login',
       invalidatesTags: cacher.invalidatesUnauthorized()
      }),
      refetchErroredQueries: builder.mutation<unknown, void>({
        queryFn() {
          return { data: {} };
        },
       invalidatesTags: cacher.invalidatesUnknownErrors()
      }),
      getTodos: builder.query<PaginatedResponse<Todo>, Options>({
        query: ({id, page = 0}) => `organisations/${id}/systems?page=${page - 1}&pageSize=10`,
       providesTags: cacher.providesList("Todo")
      }),
      addTodo: builder.mutation<Todo, Partial<Todo>>({
        query: (body) => ({
          url: "todos",
          method: "POST",
          body
        }),
       invalidatesTags: cacher.invalidatesList("Todo")
      }),
      updateTodo: builder.mutation<Todo, AtLeast<Todo, "id">>({
        query: (body) => ({
          url: "todos",
          method: "POST",
          body
        }),
       invalidatesTags: cacher.cacheByIdArgProperty("Todo")
      }),
      deleteTodo: builder.mutation<Todo[], number>({
        query: (body) => ({
          url: `todos/${body}`,
          method: "DELETE"
        }),
       invalidatesTags: cacher.cacheByIdArg("Todo")
      })
    })
  });
@Shrugsy

This comment has been minimized.

Copy link
Owner Author

@Shrugsy Shrugsy commented Sep 16, 2021

@troypoulter try this out:

type InnerProvidesNestedList<T> = <
  Results extends { data: { id: unknown }[] },
  Error extends FetchBaseQueryError
>(
  results: Results | undefined,
  error: Error | undefined
) => CacheList<T, Results["data"][number]["id"]>;

/**
 * Similar to `providesList`, but for data located at a nested property,
 * e.g. `results.data`.
 * The property is hard coded, so re-create a version of this function based
 * on a data shape your API returns for best results.
 */
export const providesNestedList = <T extends string>(
  type: T
): InnerProvidesNestedList<T> => (results, error) => {
  // is result available?
  if (results) {
    // successful query
    return [
      { type, id: "LIST" },
      ...results.data.map(({ id }) => ({ type, id } as const))
    ];
  }
  // Received an error, include an error cache item to the cache list
  return concatErrorCache([{ type, id: "LIST" }], error);
};

Part of the reason this isn't officially part of the repo is that potentially vastly different response shapes may be used for different users, and it's difficult to get make a more customizable version of this that is still properly type-safe. So the current recommendation is to create your own helpers based on the common shape your API returns. In this case, the above providesNestedList is just a modified version that looks at result.data rather than result.

For your usage, you should be able to do this:

      getTodos: builder.query<PaginatedResponse<Todo>, Options>({
        query: ({id, page = 0}) => `organisations/${id}/systems?page=${page - 1}&pageSize=10`,
       providesTags: cacher.providesNestedList("Todo")
      }),
@troypoulter

This comment has been minimized.

Copy link

@troypoulter troypoulter commented Sep 16, 2021

This is perfect thank you for such a quick and solid explanation @Shrugsy!

Confirming this works as expected and makes sense on why it isn't included in official repo. Hopefully with these two examples will cover a wide range of use cases and shows how to adapt it as needed!

I realise I used the wrong URL woops hahah and one small suggestion, I saw you updated the gist with this example but did not export providesNestedList at the bottom.

Thank you again for your help, this will make using RTK Query which simpler with these utils!

@Shrugsy

This comment has been minimized.

Copy link
Owner Author

@Shrugsy Shrugsy commented Sep 17, 2021

Thanks @troypoulter, I've updated the export.

p.s., feel free to drop by the #redux channel in the reactiflux discord if you have questions about any other redux related topics, including RTK Query

@troypoulter

This comment has been minimized.

Copy link

@troypoulter troypoulter commented Sep 17, 2021

Thanks @Shrugsy! I didn't know that community existed have joined now and will keep in mind for the future many questions hahah 🔥

@akursat

This comment has been minimized.

Copy link

@akursat akursat commented Oct 12, 2021

@Shrugsy why we're invalidating all list? Isn't better to invalidate only affected ids?

@Shrugsy

This comment has been minimized.

Copy link
Owner Author

@Shrugsy Shrugsy commented Oct 12, 2021

@akursat deciding whether to invalidate the 'LIST' tag depends on what the mutation does.

e.g.

  • If you're adding a new item, you would invalidate 'LIST' such that a query that provides a whole list will re-fetch with that new item. In that scenario, it won't have been providing the specific ID before then, because it didn't exist in the list.
  • If you're editing an existing item, invalidating specifically the affected ID is fine, and the query that fetches the list will also re-fetch regardless, since it will be providing that ID already.
@akursat

This comment has been minimized.

Copy link

@akursat akursat commented Oct 12, 2021

I see your point. Thank you for your quick response.

@sorokinvj

This comment has been minimized.

Copy link

@sorokinvj sorokinvj commented Oct 19, 2021

Awesome gist, thanks!

However, did I understand it right, that if you use queryFn on a query, then there is no way to provide cache id and as a result, you can't do optimistic updates for that particular query? @Shrugsy

@Shrugsy

This comment has been minimized.

Copy link
Owner Author

@Shrugsy Shrugsy commented Oct 19, 2021

Hi @sorokinvj,

Whether you use queryFn or query, the cache id is still the same. The argument you call the query with is used in conjunction with the endpoint name to generate the 'cache key'.

e.g. for the below:

endpoints: (build) => ({
  getFoo: build.query<Foo, number>({
    query: (id) => `foo/${id}`,
  }),
  getBar: build.query<Bar, number>({
    queryFn: (id) => ({
      data: id
    }),
  }),
})

The getFoo endpoint uses query in conjunction with the base query, and takes a number as an argument. So if the generated hook is called like useGetFooQuery(5), it will have a cache key of getFoo(5).
The getBar endpoint uses queryFn, and takes a number as an argument. So if the generated hook is called like useGetBarQuery(5), it will have a cache key of getBar(5).

For an optimistic update, you need to provide the name of the endpoint you're updating the cache for, and the argument used that corresponds to the cache entry. So whether using query or queryFn, it works the same.

e.g. within a mutation's onQueryStarted:

api.util.updateQueryData('getFoo', 5, (draft) => { /* updates go here */ });

or

api.util.updateQueryData('getBar', 5, (draft) => { /* updates go here */ });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment