ari-cake
ari-cake3w ago

Easier Optimistic Updates

Hi, just wanted to share a small helper for optimistic updates. First, here's some example usage:
// With this helper
useOptimisticMutation(api.docs.setContent)
.withUptimisticQuery(api.docs.getContent, (value, qArgs, mutArgs) => {
if (qArgs.doc == mutArgs.doc) {
value.content = mutArgs.content;
}
})
.withUptimisticQuery(api.docs.get, (value, _, mutArgs) => {
for (const p of value.page) {
if (p._id == mutArgs.doc) {
p.title = getTitle(mutArgs.content);
}
}
});
// With this helper
useOptimisticMutation(api.docs.setContent)
.withUptimisticQuery(api.docs.getContent, (value, qArgs, mutArgs) => {
if (qArgs.doc == mutArgs.doc) {
value.content = mutArgs.content;
}
})
.withUptimisticQuery(api.docs.get, (value, _, mutArgs) => {
for (const p of value.page) {
if (p._id == mutArgs.doc) {
p.title = getTitle(mutArgs.content);
}
}
});
The helper is the withOptimisticQuery function - you get the return value of all queries, and can easily just mutate the state - even if you don't know the exact args, etc. It's all 100% TypeSafe, too, and it avoids unnecessary updates. Thanks to immer it also does treat everything as immutable, but you can still use it as you're used to :) For comparision, here's the mutation with "plain" convex:
// BEFORE, without the function
useMutation(api.docs.setContent).withOptimisticUpdate((ctx, mutArgs) => {
const docContent = ctx.getQuery(api.docs.getContent, { doc: mutArgs.doc });
if (docContent) {
ctx.setQuery(
api.docs.getContent,
{ doc: mutArgs.doc },
{
...docContent,
content: mutArgs.content,
},
);
}

const newTitle = getTitle(mutArgs.content);
// Alright, let's iterate all document lists
for (const entry of ctx.getAllQueries(api.docs.get)) {
if (!entry.value) {
continue;
}

for (const doc of entry.value.page) {
if (doc._id == mutArgs.doc) {
// now, the fun begins - creating an update :)
const update = {
...entry.value,
page: entry.value.page.with(entry.value.page.indexOf(doc), {
...doc,
title: newTitle,
}),
} satisfies (typeof entry)["value"];

ctx.setQuery(api.docs.get, entry.args, update);
}
}
}
// BEFORE, without the function
useMutation(api.docs.setContent).withOptimisticUpdate((ctx, mutArgs) => {
const docContent = ctx.getQuery(api.docs.getContent, { doc: mutArgs.doc });
if (docContent) {
ctx.setQuery(
api.docs.getContent,
{ doc: mutArgs.doc },
{
...docContent,
content: mutArgs.content,
},
);
}

const newTitle = getTitle(mutArgs.content);
// Alright, let's iterate all document lists
for (const entry of ctx.getAllQueries(api.docs.get)) {
if (!entry.value) {
continue;
}

for (const doc of entry.value.page) {
if (doc._id == mutArgs.doc) {
// now, the fun begins - creating an update :)
const update = {
...entry.value,
page: entry.value.page.with(entry.value.page.indexOf(doc), {
...doc,
title: newTitle,
}),
} satisfies (typeof entry)["value"];

ctx.setQuery(api.docs.get, entry.args, update);
}
}
}
Anyway, needless to say, I prefer the new version. Funnily enough, the "before" version also likely causes more re-renders. immer doesn't create an optimistic update if the old and new title are the same. In my code, I think I'd have to check myself
1 Reply
ari-cake
ari-cakeOP3w ago
import type { OptimisticLocalStore } from "convex/browser";
import { useMutation, type ReactMutation } from "convex/react";
import type { FunctionArgs, FunctionReference, FunctionReturnType } from "convex/server";
import { produce, setAutoFreeze } from "immer";

setAutoFreeze(false);
function updateQuery<Query extends FunctionReference<"query">>(
ctx: OptimisticLocalStore,
query: Query,
mapToDoc: (val: NonNullable<FunctionReturnType<Query>>, args: FunctionArgs<Query>) => void,
) {
const queries = ctx.getAllQueries(query);
for (const { value, args } of queries) {
if (value == null) {
continue;
}
const updated = produce<NonNullable<FunctionReturnType<Query>>>(value, (d) =>
mapToDoc(d, args),
);
if (value != updated) {
ctx.setQuery(query, args, updated);
}
}
}

type UpdateFn<
Mutation extends FunctionReference<"mutation">,
Query extends FunctionReference<"query">,
> = (
val: NonNullable<FunctionReturnType<Query>>,
args: FunctionArgs<Query>,
mutationArgs: FunctionArgs<Mutation>,
) => void;

type WithUpdateQuery<Mutation extends FunctionReference<"mutation">> = ReactMutation<Mutation> & {
withOptimisticQuery<Query extends FunctionReference<"query">>(
query: Query,
update: UpdateFn<Mutation, Query>,
): WithUpdateQuery<Mutation>;
};

type OptimisticMutationOp<
Mutation extends FunctionReference<"mutation">,
Query extends FunctionReference<"query">,
> = {
query: Query;
updateFn: UpdateFn<Mutation, Query>;
};

export function useOptimisticMutation<Mutation extends FunctionReference<"mutation">>(
mutation: Mutation,
): WithUpdateQuery<Mutation> {
const todo: OptimisticMutationOp<Mutation, FunctionReference<"query">>[] = [];

const reactMutation = useMutation(mutation).withOptimisticUpdate((ctx, mutArgs) => {
for (const entry of todo) {
updateQuery(ctx, entry.query, (val, args) => entry.updateFn(val, args, mutArgs));
}
}) as WithUpdateQuery<Mutation>;

reactMutation.withOptimisticQuery = (query, updateFn) => {
todo.push({
query,
updateFn: updateFn as any /* idk how to not any here */,
});

return reactMutation;
};

return reactMutation;
}
import type { OptimisticLocalStore } from "convex/browser";
import { useMutation, type ReactMutation } from "convex/react";
import type { FunctionArgs, FunctionReference, FunctionReturnType } from "convex/server";
import { produce, setAutoFreeze } from "immer";

setAutoFreeze(false);
function updateQuery<Query extends FunctionReference<"query">>(
ctx: OptimisticLocalStore,
query: Query,
mapToDoc: (val: NonNullable<FunctionReturnType<Query>>, args: FunctionArgs<Query>) => void,
) {
const queries = ctx.getAllQueries(query);
for (const { value, args } of queries) {
if (value == null) {
continue;
}
const updated = produce<NonNullable<FunctionReturnType<Query>>>(value, (d) =>
mapToDoc(d, args),
);
if (value != updated) {
ctx.setQuery(query, args, updated);
}
}
}

type UpdateFn<
Mutation extends FunctionReference<"mutation">,
Query extends FunctionReference<"query">,
> = (
val: NonNullable<FunctionReturnType<Query>>,
args: FunctionArgs<Query>,
mutationArgs: FunctionArgs<Mutation>,
) => void;

type WithUpdateQuery<Mutation extends FunctionReference<"mutation">> = ReactMutation<Mutation> & {
withOptimisticQuery<Query extends FunctionReference<"query">>(
query: Query,
update: UpdateFn<Mutation, Query>,
): WithUpdateQuery<Mutation>;
};

type OptimisticMutationOp<
Mutation extends FunctionReference<"mutation">,
Query extends FunctionReference<"query">,
> = {
query: Query;
updateFn: UpdateFn<Mutation, Query>;
};

export function useOptimisticMutation<Mutation extends FunctionReference<"mutation">>(
mutation: Mutation,
): WithUpdateQuery<Mutation> {
const todo: OptimisticMutationOp<Mutation, FunctionReference<"query">>[] = [];

const reactMutation = useMutation(mutation).withOptimisticUpdate((ctx, mutArgs) => {
for (const entry of todo) {
updateQuery(ctx, entry.query, (val, args) => entry.updateFn(val, args, mutArgs));
}
}) as WithUpdateQuery<Mutation>;

reactMutation.withOptimisticQuery = (query, updateFn) => {
todo.push({
query,
updateFn: updateFn as any /* idk how to not any here */,
});

return reactMutation;
};

return reactMutation;
}
Dependencies: immer I might make this an npm package (needs a bit of cleanup tho), but tbh, I feel like sth like this should be part of core convex. Also: All this code is licensed under MIT / and/or WTFPL - up to the user. As in: convex core team, feel free to play around with this. Also, question to convex team: have you considered looking into autogenerating some optimistic updates in some way? I think there's some real opportunity there, esp. if you do symbolic execution. The disadvantage is, of course, it becomes entirely unpredictable whether your optimistic stuff will work automagically or not, which is sad. Hm.

Did you find this page helpful?