cyremur
cyremur2y ago

optimistic updates

soooooooooooooo optimisticUpdates are implemented and now I'm getting definitely a feeling of boilerplate and losing some easiness in my offboarding story, that took way more work than it should have and I introduced some bugs due to me having to restructure functions and naming being hard...
17 Replies
ballingt
ballingt2y ago
@cyremur hey can we hop in a thread with this
cyremur
cyremurOP2y ago
So all my stuff now looks like this:
// page.tsx
const actionOneMutation = useMutation(
api.games.convexActionOne
).withOptimisticUpdate((localStore, args) => {
const state = _.cloneDeep(
localStore.getQuery(api.items.get, { id: args.id })
);
if (!state) {
return;
}
actionOne(state, args);
localStore.setQuery(api.items.get, { id: args.id }, state);
});

// items.ts
async function loadItem(db: DatabaseReader, id: Id<"items">) {
const dbItem =
(await db.get(id)) ?? raise(`Item with id ${id} doesn't exist.`);
return convertStateForUserSpace(dbItem);
}

export const convexActionOne = mutation({
args: { id: v.id("items"), argAlpha: any, argBeta: any },
handler: async ({ db }, args) => {
const item = await loadItem(db, args.id);
actionOne(item, args);
await db.replace(args.id, convertStateForDB(item));
},
});

// logic.ts
export const actionOne = (
state: StateFromSchema,
args: { id: v.id("items"), argAlpha: any, argBeta: any },
) => {
const { argAlpha, argBeta } = args;
// a bunch of statements that mutate state based on args
// yes, includes another actionOneHelper or whatever cause I'm running out of function names
};
// page.tsx
const actionOneMutation = useMutation(
api.games.convexActionOne
).withOptimisticUpdate((localStore, args) => {
const state = _.cloneDeep(
localStore.getQuery(api.items.get, { id: args.id })
);
if (!state) {
return;
}
actionOne(state, args);
localStore.setQuery(api.items.get, { id: args.id }, state);
});

// items.ts
async function loadItem(db: DatabaseReader, id: Id<"items">) {
const dbItem =
(await db.get(id)) ?? raise(`Item with id ${id} doesn't exist.`);
return convertStateForUserSpace(dbItem);
}

export const convexActionOne = mutation({
args: { id: v.id("items"), argAlpha: any, argBeta: any },
handler: async ({ db }, args) => {
const item = await loadItem(db, args.id);
actionOne(item, args);
await db.replace(args.id, convertStateForDB(item));
},
});

// logic.ts
export const actionOne = (
state: StateFromSchema,
args: { id: v.id("items"), argAlpha: any, argBeta: any },
) => {
const { argAlpha, argBeta } = args;
// a bunch of statements that mutate state based on args
// yes, includes another actionOneHelper or whatever cause I'm running out of function names
};
sure sorry for putting so much stuff in general
ballingt
ballingt2y ago
Do you have a ton of these, or mostly this one that you pass arbitrary game actions to? I have a few small thoughts but curious what your thoughts on this are re this pattern, I could imagine a db.mustGet or db.getOrThrow
(await db.get(id)) ?? raise(`Item with id ${id} doesn't exist.`);
(await db.get(id)) ?? raise(`Item with id ${id} doesn't exist.`);
and in my own code I write a
function mustGet<T extends Id>(db: DatabaseReader, id) {
return db.get() ?? raise
}
function mustGet<T extends Id>(db: DatabaseReader, id) {
return db.get() ?? raise
}
sort of thing
cyremur
cyremurOP2y ago
so I basically have -initializeGame -startGame -executeAbility -playCard -passTurn first two are mostly for matchmaking, last three carry the most of the gameplay that was the level of abstraction that felt reasonable with redux one answer I was considering is basically doing one-level-higher and have just a getGame query and mutateGame mutation and then pass everything as parameters and select subfunctions and all that fun another thing I was considering was higher level functions that handle the loadState / saveState at the start and end of optimistic updates
ballingt
ballingt2y ago
neither here nor there but I did this the other day with a class, no fewer lines of code but as long as it's a single object that only makes sense to modify together might as well slap some behavior on it
cyremur
cyremurOP2y ago
so I can do something like
.withOptimisticUpdate(
localMutate(actionOne, args)
)
.withOptimisticUpdate(
localMutate(actionOne, args)
)
and then localMutate basically returns the optimistcUpdate handler that loads state, executes actionOne with args on state, and then saves state to localStore what was the scope of the class?
ballingt
ballingt2y ago
I made my Game object a class, it has class methods .fromRecord() and toRecord() but once it was a class instance I could call methods on it like playTurn it just lasted for a single mutation
cyremur
cyremurOP2y ago
yeah I kinda like stuff like entity.attack(entity) and putting stuff like that on the actual game objects that's how we learned SWE couple years ago with all the OOP patterns
ballingt
ballingt2y ago
somewhat because it helped me unit test that logic all separately from my DB stuff
cyremur
cyremurOP2y ago
I kinda started like that and then got a headache over synchronizing class objects with functions over web and instead of writing a transport layer with serialization I just gave up and made everything plain data and functions now I just have a big json in the middle and a bunch of functions that represent gameactions that move json values around and while I would do
game.selectDeck(playerId, deckId)
game.selectDeck(playerId, deckId)
over
selectDeck(game, playerId, deckId)
selectDeck(game, playerId, deckId)
anytime it's not enough of a pain point that I want to deal with classes and serialization just for some syntactic sugar and autocomplete support
ballingt
ballingt2y ago
Once you find patterns you like you might choose to abstract them with "middleware" (just helper functions that run every time) — we don't provide anything ORM-y but a wrapper that passes you a game object or even adds a method to db is an option
cyremur
cyremurOP2y ago
I forgot for a moment that I implemented attacks of opportunities and panicked that I broke movement with optimistic updates when my creature vanished... game log says it was fine though... phew honestly, the biggest piece of "middleware" are executeAbility and manifestCard they take so many args that they're basically half a game engine the thing that I would really consider "middleware" though is minification of the gamestate on client side I work with a 50kb json that has like 60 full copies of playing cards in the state which is compressed to 10kb by swapping out the full rich card jsons with just cardIds
ballingt
ballingt2y ago
I mention these because you mention boilerplate before, things like https://github.com/get-convex/convex-helpers/blob/main/convex/lib/withUser.ts mostly from @ian are slick for boilerplate reduction once you figure out which parts of your app you want to be looking at and which you don't
cyremur
cyremurOP2y ago
thanks, will definitely have another look the main feels bad for me was having the same load-action-save implemented in both the convex function serverside and the optimistic update client side... obviously not the general case that the optimistic update has all the code and info to execute 100% of the change independently so hard to complain this does look like a really cool repo
ballingt
ballingt2y ago
totally, that makes sense
cyremur
cyremurOP2y ago
ok but I'm somewhat optimistic that all the infra changes are just kinda done now and I can go back to game logic and asset work only thing left is auth and account management
Michal Srb
Michal Srb2y ago
Really cool stuff. I think Ian mentioned it but I would scope down mutable stuff via Immer, and avoid deep cloning. Calling the same logic from optimistic update and server-side sounds totally reasonable - but for games you might do that a lot more than for a typical "app". It might be interesting for you to eventually split up the game state (if possible). Convex gives you guarantees like "consistent view" of all your queries, that should enable this.

Did you find this page helpful?