Add helpers for optimistic updates
Right now the standard pattern for an optimistic update is pretty verbose:
const api = createApi({
baseQuery: fetchBaseQuery({
baseUrl: '/',
}),
tagTypes: ['Post'],
endpoints: (build) => ({
getPost: build.query<Post, number>({
query: (id) => `post/${id}`,
providesTags: ['Post'],
}),
updatePost: build.mutation<void, Pick<Post, 'id'> & Partial<Post>>({
query: ({ id, ...patch }) => ({
url: `post/${id}`,
method: 'PATCH',
body: patch,
}),
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
api.util.updateQueryData('getPost', id, (draft) => {
Object.assign(draft, patch)
}),
)
try {
await queryFulfilled
} catch {
patchResult.undo()
/**
* Alternatively, on failure you can invalidate the corresponding cache tags
* to trigger a re-fetch:
* dispatch(api.util.invalidateTags(['Post']))
*/
}
},
}),
}),
})
You have to:
- Implement
onQueryStarted - Do
const patchResult = dispatch(api.util.updateQueryData(endpointName, arg, updateCb) await queryFulfilled- catch that and call
patchResult.undo()
We could add a new option and a new lifecycle API method to simplify this.
Conceptual idea:
type OptimisticUpdateDefinition {
endpointName: EndpointName;
arg: Arg;
update: (draft) => T
}
// then in a mutation endpoint:
applyOptimistic: (mutationArg: Arg, maybeSomeLifecycleApi: LifecycleApi) => (OptimisticUpdateDefinition | PatchCollection)[]
That way, the simple approach is to just list some endpoints you want updated, or you can import and reference another API slice to handle more complex cases where this API slice doesn't know about the endpoint:
applyOptimistic: (id) => [
// simple case
{endpointName: "todos", arg: id, update: (todos) => todos.push({id})},
// more complex
anotherApi.util.updateQueryData("otherTodos", id, (todos) => todos.push({id})
]
Internally, that would then convert the "simple" cases to PatchCollections as well, and then we could dispatch those, calling patchResult.undo() if needed. We could even consider batching all of them into a single dispatch, and still revert the patches individually if needed.
We could also add a lifecycleApi.applyOptimistic() helper that does the try/catch wrapping.
We could also also do the same thing for pessimistic updates.
Design questions:
- What if we actually specified
"optimistic"or"pessimistic"per entry? Would that simplify the internal implementation any? - What happens if the user defined
onQueryStartedthemselves? How does that interact? - I've also wanted to do something similar with
upsertQueryEntries()for a while - being able to do something likeupsertEntries: (results) => results.map(todo => ({endpointName: "getTodo", arg: todo.id, value: todo})), to automatically do things like prefilling individual-item cache entries from list endpoint results. That's probably a different feature, but it feels adjacent here.
I suppose we could just pass updateQueryData in as an argument to the applyOptimistic callback - it probably ends up being the same thing as defining the object, but without the need to list the field names each time. But maybe rename it? And make it in an object just to preserve flexibility if we're maybe providing other methods like getState?
applyOptimistic: (id, {optimisticUpdate}) => [
// simple case
optimisticUpdate("todos", id, (todos) => todos.push({id})),
// more complex
anotherApi.util.updateQueryData("otherTodos", id, (todos) => todos.push({id})
]
Yes I know there's a discontinuity in naming there, just tossing out thoughts.
Copy-pasting more brain-dumping from Discord:
- almost all of our internal update patterns have been "update this one specific cache entry". similar to how we've always essentially treated our endpoint usage as individual, but we've seen folks want to do larger-level coordination of some kind.
- perf-wise, this ends up being bad for any case that updates many things, because you have to do individual dispatches (see
upsertQueryData) upsertQueryEntrieswas the first shipped example of doing something that applied to many cache entries at once- I had suggested I think, and you had draft-PRed, a "batch patches" util, that's still hanging out there
- batching patches into a single dispatch actually makes sense perf-wise as an approach
- in terms of implementing this feature, generating objects that specify
"optimistic"or"pessimistic"feels right somehow even though I haven't had time to think through the ramifications - when I was designing and implementing
upsertQueryEntries, I intentionally wanted to then take the later step of adding an endpoint option that let you do things like map over a response and return a list of entries to upsert (iegetTodos->getTodo(id)) - the design we're talking about here for optimistic is basically that idea
- so if we're going to do this, why not approach this in a way that unifies all three (optimistic, pessimistic, upserts) somehow architecturally?
- and if we're going to do that, we should also do it in a perf-efficient-dispatch approach somehow
I don't think we can do both optimistic and pessimistic in one callback, because pessimistic would require access to the response.
so maybe we have both applyOptimistic and applyPessimistic, and both allow upserts too?
It would also be nice if you could optimistically update a bunch of queries (e.g. all queries for getPosts: getPosts({ by: "john" }), getPosts({ by: "jane" }), etc. where you find the post with a given ID and patch it using some patch function). Additionaly I'd like to see a way to only apply optimistic updates to queries with an active subscription and allow to simply purge queries without an active subscription. This avoids unnecessary computation to update cached queries that will most likely never be active again.
This would be amazing. Optimistic and pessimistic helpers would make things so much cleaner.
Yeah, @EskiMojo14 tried prototyping something, but I believe ran into problems with type circularity. We really want it all to be typesafe, but it sounded like it ran into TS not knowing what endpoint A is when you try to reference it in endpoint B, because the endpoints haven't been inferred as you're still in the middle of defining them. Similar problem pops up if you try to do something like dispatch(endpointA.initiate()) inside of endpoint B's queryFn. Doesn't seem like we have a solution for that yet.