-
-
Save Shrugsy/6b6af02aef1f783df9d636526c1e05fa to your computer and use it in GitHub Desktop.
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 | |
}; |
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
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 */ });
Hey again @Shrugsy, been using this a lot at work and been humming along like a charm - thank you for also adding the tests for it!
I've encountered an issue where on one of my getAll
style endpoints where I use cacher.providesNestedList(Tag)
- the call will always fail due to a server issue.
This is expected and I thought it would show the error and not load but instead it is in a continual re-fetch loop until you change page/component unsubscribes.
How do you reckon is best to approach this? In our use case, if one of our getAll
endpoints fail we don't really want/need to re-fetch if that info is relevant.
But yeah found the behaviour quite strange.
@troypoulter could you try the beta for v1.7.0? https://github.com/reduxjs/redux-toolkit/releases/tag/v1.7.0-beta.0
Under the current design, you can enter an infinite loop if a query argument is a nested object and the query returns an error. A single object level is accounted for, but a nested object will be considered a new argument each time, so the loop is essentially that it sees an error, returns that error out of the hook, sees a new argument, re-tries, and repeat.
In v1.7.0, the serialization for query arguments has been re-written, so a nested object argument will still be considered the same argument if it serializes the same way.
@Shrugsy thanks a lot!
My question was actually "inspired" by the nasty bug I discovered in my code, one of the query params to my surprise turned out to be a number where a expected a string, so the api.util.updateQueryData
wasn't updating anything and I thought that's maybe because queryFn instead of query (I don't know why I thought that, but it was after 2 days of unsuccessful tries:). Anyway, now its solved, thanks for detailed clarification!
@troypoulter could you try the beta for v1.7.0? https://github.com/reduxjs/redux-toolkit/releases/tag/v1.7.0-beta.0
Under the current design, you can enter an infinite loop if a query argument is a nested object and the query returns an error. A single object level is accounted for, but a nested object will be considered a new argument each time, so the loop is essentially that it sees an error, returns that error out of the hook, sees a new argument, re-tries, and repeat.
In v1.7.0, the serialization for query arguments has been re-written, so a nested object argument will still be considered the same argument if it serializes the same way.
Apologies for the delay, as I upgraded to the beta the backend team fixed the issue so had to fiddle around to replicate it and appears to be behaving as normal.
Thank you for your assistance again @Shrugsy 🔥
I've read the conversation between @troypoulter and @Shrugsy about providesNestedList and InnerProvidesNestedList which is very convenient with REST APIs.
But, in my case, the paginated API responds the data according to the request. I mean:
If I request getUsers
, the API responds:
{
totalCount: number;
totalPage: number;
currentPage: number;
users: User[]; // <--------- here is users, not data !
}
If I request getAuthusers
, the API responds:
{
totalCount: number;
totalPage: number;
currentPage: number;
authusers: Authuser[]; // <--------- here is authusers, not data !
}
If I request getBooks
, the API responds:
{
totalCount: number;
totalPage: number;
currentPage: number;
books: Book[]; // <--------- here is books, not data !
}
and so forth.
In this case I suppose, I have to add another parameter (relevant key string) into providesNestedList in order to read it from response through response[key]
, because it is not always response.data
; which could be response.users
, or response.authusers
or response.books
etc.
// for the getUsers endpoint
providesTags: cacher.providesNestedList("User", "users");
// for the getAuthusers endpoint
providesTags: cacher.providesNestedList("Authuser", "authusers");
// for the getBooks endpoint
providesTags: cacher.providesNestedList("Book", "books");
But I don't know how to pass the parameter key into ** InnerProvidesNestedList** in order to provide type inference for the type Results
.
type InnerProvidesNestedList<T> = <
Results extends { data: { id: unknown }[] }, // <---- it is not always data any more, could be users, authusers, books or could be data in the case of @troypoulter as well !!
Error extends FetchBaseQueryError
>(
results: Results | undefined,
error: Error | undefined
) => CacheList<T, Results["data"][number]["id"]>; // <---- and here, the key is not always "data" !
If we re-arrange providesNestedList and InnerProvidesNestedList, this two functions could be more generic, and I need this for my case indeed.
Possible solution you could use for a dynamic key in a nested list
type InnerProvidesNestedList<T, K extends string> = <
Results extends { [Key in K]: { id: unknown }[] },
Error extends FetchBaseQueryError
>(
results: Results | undefined,
error: Error | undefined
) => CacheList<T, Results[K][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, K extends string>(type: T, key: K): InnerProvidesNestedList<T, K> =>
(results, error) => {
// is result available?
if (results) {
// successful query
return [
{ type, id: 'LIST' },
...results[key].map(({ id }) => ({ type, id } as const)),
];
}
// Received an error, include an error cache item to the cache list
return concatErrorCache([{ type, id: 'LIST' }], error);
};
// for the getUsers endpoint
providesTags: cacher.providesNestedList("User", "users");
// for the getAuthusers endpoint
providesTags: cacher.providesNestedList("Authuser", "authusers");
// for the getBooks endpoint
providesTags: cacher.providesNestedList("Book", "books");
Thank you @hrafnkellbaldurs, it has worked.
Now, the cacher utility is available for the server responses having the actual records/list in a "dynamic key"
rather than hard-coded "data"
key.
My recommendation to other developers is to keep the function providesNestedList
and the type InnerProvidesNestedList
as it is and to add the new function asprovidesNestedDynamicList
and the new type as InnerProvidesNestedDynamicList
which @hrafnkellbaldurs has provided in order to have flexibility.
Thanks again.
I see your point. Thank you for your quick response.