
Optimistic UI Updates for near instant feedback
On this page
Quick recap
Timekeeper is a weekend side project: a weekly grid for time tracking with task CRUD plus quick-add workflows like activity batches and calendar suggestions. It syncs with Firebase, but the goal is instant feedback, so the UI updates before the server confirms anything. 1
In the last post, I shared the motivations, performance design goals, and architecture of Timekeeper. You can read the previous post here. This post dives into the optimistic UI patterns I used to make the app feel fast and responsive.
The optimistic update pattern I use
Most data writes flow through TanStack Query mutation lifecycles. The pattern looks like this:
- Cancel refetches so stale server data cannot overwrite the optimistic UI. 2
- Snapshot the cache for rollback.
- Write optimistic data into the cache (often with a temporary id).
- Rollback on error using the snapshot and show a toast.
- Replace with server data on success (no duplicate rows). 3
A trimmed example from task creation shows the core mechanics:
onMutate: async (newTask) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] })
const previousQueries = queryClient.getQueriesData({ queryKey: ['tasks'] })
const optimisticTask: Task = {
id: `temp-${Date.now()}`,
...newTask,
createdAt: Timestamp.now(),
updatedAt: Timestamp.now(),
}
previousQueries.forEach(([queryKey, oldData]) => {
if (!oldData) return
queryClient.setQueryData(queryKey, [...(oldData as Task[]), optimisticTask])
})
return { previousQueries }
},Then I reconcile in onSuccess by swapping out the temp task and invalidating only the dependent counters (like pending task count), rather than refetching everything.
Calendar suggestions and event mappings
Calendar suggestions are more nuanced because they blend Firestore state and derived UI state. Two pieces make this feel instantaneous:
- useCalendarEventMappings mutations update the cache directly in
onMutateand replace with server data inonSuccess. This avoids the race condition where a quick refetch could wipe optimistic mappings. - useSuggestionStore
optimisticAcceptedIdsacts as a short-lived local filter. When a user accepts a suggestion, I hide it immediately even before the server writes a task. Once tasks arrive, the store self-cleans based on the task list . 4
Dismissals and batch actions
Suggestion dismissals also use optimistic entries in the query cache. Batch task creation follows the same pattern as single creation but adds multiple optimistic tasks at once and replaces them in a single pass.
Guardrails I rely on
- Avoid
invalidateQueriesinonSettledfor optimistic flows. I already have the right state in cache and a refetch can clobber it. 5 - Keep optimistic IDs scoped (
temp-*) so it is easy to replace them later. - Only invalidate what you must (e.g., pending task count), not the primary list.
The core idea: trust the local cache for the UI, then reconcile with the server once it responds.
Footnotes
-
Decoupling from the Firestore SDK is often a performance-driven choice in webview environments like Tauri. Relying on an abstraction like TanStack Query avoids the event-loop contention that can occur when heavy SDK listeners run directly against the UI thread. ↩
-
Per TkDodo's Mastering Mutations in React Query,
cancelQueriesis the most critical step in avoiding the "overwrite" bug. If a background refetch is not halted, it may return the "old" server state and delete your optimistic item before the mutation completes. ↩ -
This "Manual Update" strategy is detailed in the TanStack Query documentation as the standard for high-performance applications where data consistency and immediate feedback are both non-negotiable. ↩
-
This "Layered State" approach (using a local store for transient flags and React Query for persisted data) is a common architectural pattern used to manage "Ephemeral UI" without polluting the server cache with short-lived client states. As Tanner Linsley notes, "your UI state is not the same as your server state and those two should be separate things". ↩
-
In high-frequency apps, avoiding
onSettledinvalidation is known as Manual Reconciliation. While the default recommendation is to invalidate, doing so can cause unnecessary network chatter and "flicker" that breaks the illusion of an instant, local-first interface. ↩