megaserg
megaserg3y ago

Optimistic updates for debounced mutations

I would like to have an optimistic update where the mutation is debounced (not single-flighted, but debounced) My usecase: When user types text in the field, the text should get saved in the database, but only after 1 sec after the user stopped typing. Meanwhile, another user looking at the same page should see the edited text as it gets updated in the database. Kind of like Google Doc. Can I have no local useState and just have the value as useQuery result, and apply mutation withOptimisticUpdate? But how to introduce debouncing? Alternatively, I could keep local useState, and have my custom debounce() wrapper which sets timeout and so on. And wrap mutation with that, and call debouncedMutation onChange. But then what about the other user? Should I Watch the query and update local state there?
7 Replies
ballingt
ballingt3y ago
Optimistic updates should be one-to-one with real mutations, so I would keep the useState and use a debounced mutation.
But then what about the other user? Should I Watch the query and update local state there?
You're going to need to "rebase" another user's changes when they have a not-yet-committed change. Optimistic updates in Convex automatically reapply when new query results arrive, but - your useState changes won't be reapplied like this, and - the optimistic updates would be a problem anyway, they're too coarse If you want really collaborative editing, you probably want ProseMirror collab or similar it does content-aware diffs, taking care of the rebase step aka operational transforms But simpler would be "informal locking" or whatever folks call it, where you show that someone is currently typing in a text box
megaserg
megasergOP3y ago
Suppose I don't do withOptimisticUpdate and let's say I resolve conflicts like "local version trumps remote version if user is typing, remote trumps local otherwise" (poor man's OT/CRDT lol). Then how would the non-typing user get updates? How do I bound their local state with query result?
ballingt
ballingt3y ago
I would make the useState part of your debounced mutation, it should reset as soon as there is no more pending mutation const {state, setState} useQueryOrLocalStateIfPending() and the implementation of this custom hook uses useQuery("textBoxState") Let me know if more details would be helpful. Without testing it, something like this
function useQueryOrLocalStateIfPending() {
const [localState, setLocalState] = useState();
const [timer, setTimer] = useState();
const mut = useMutation('changeTextbox');
const remoteState = useQuery('textbox');
const debouncedMut = (val) => {
clearTimeout(timer);
setTimer(setTimeout(async () => {
setTimer(null);
mut(val);
}, 1000))
}

return {
setState: debounced
state: timer ? localstate : remoteState
}
}
function useQueryOrLocalStateIfPending() {
const [localState, setLocalState] = useState();
const [timer, setTimer] = useState();
const mut = useMutation('changeTextbox');
const remoteState = useQuery('textbox');
const debouncedMut = (val) => {
clearTimeout(timer);
setTimer(setTimeout(async () => {
setTimer(null);
mut(val);
}, 1000))
}

return {
setState: debounced
state: timer ? localstate : remoteState
}
}
megaserg
megasergOP3y ago
Thanks a lot! I'll experiment and report back. Haven't written custom hooks before
ballingt
ballingt3y ago
You can do this directly in a component too, custom hooks are just for abstracting code from components
megaserg
megasergOP2y ago
@ballingt This works almost verbatim! I made debouncedMut also setLocalState before clearTimeout. And also swapped mut(val) with setTimer(null) so that the typing user doesn't have a microglitch while timer is already null but mutation+query has not applied+refreshed yet.
ballingt
ballingt2y ago
this makes me think of https://youtu.be/cCOL7MC4Pl0?t=107, I don't think the second swap is necessary but I agree with you and Jake in the video, I would still swap it in this case I think setState may synchronously cause a render, but it will not update the screen until the mut(val) been invoked too Oh and I guess this code as I wrote it requires that you implement optimistic updates too! If you don't, you'll want to wait until the promise resolves to stop using local state

Did you find this page helpful?