Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

Optimistic Mutations

A generic hook applies a local state change instantly on user action and automatically rolls back to the previous state if the server call fails, making every mutation feel instant without custom try/catch boilerplate.

  • Tier: Core
  • Status: Static
  • Always enabled

WHY IT MATTERS

Users judge an app by how fast it reacts to their input. A button that waits for a network round-trip before updating the screen feels broken even when the server is healthy, because mobile connections add 100-400ms of latency before the first byte arrives. The standard fix is to copy local state, apply the change immediately, fire the request in the background, and undo if it fails. That three-step pattern is straightforward to describe but tedious to implement correctly. It requires a snapshot before the update, a try/catch that restores the snapshot on error, a way to surface the error without losing the corrected state, and a guard against launching a second mutation while the first is still in flight. Each of these edge cases must be handled consistently across every screen that writes data, which compounds maintenance cost as the app grows.

The gas-template ships useOptimisticMutation, a generic React hook that encodes the full pattern once. You provide three functions: mutate (the async server call), optimisticUpdate (how the local state should change immediately), and an optional reconcile (how to merge the server response back once it arrives). The hook applies the optimistic update synchronously inside a setState call that captures a snapshot of the previous state, fires the mutation, and calls reconcile on success or restores the snapshot on rejection. It exposes a pending flag for disabling the trigger UI and an error value for inline feedback. Concurrent calls while a mutation is in flight are rejected with a typed error rather than silently queuing, so you always know the app is in a single coherent state. The result is that every list reorder, toggle, rename, and delete in a generated Goodspeed app feels immediate, with no per-feature try/catch scaffolding required.

HONEST LIMITS

When Optimistic Mutations is the wrong choice

Optimistic mutations are the right default for low-stakes reversible actions: toggling a setting, reordering a list, editing a display name, marking a task complete. They are the wrong choice when the action is destructive or financially consequential. If a user taps "Delete account" and the UI disappears before the server confirms, you have shown them a false state that may differ from reality for hundreds of milliseconds. For most deletion flows this is a small risk, but for money transfers, order submissions, and account-level actions the cost of a false confirmation outweighs the latency benefit. In those cases, keep the loading state and only update the UI after the server responds. The rule of thumb: if you would not want a user to take a second action based on a state that has not yet been confirmed by the server, do not make that state transition optimistic. A subtler limit is high-frequency concurrent updates from multiple sources. useOptimisticMutation rejects a second call while the first is in flight, which prevents state corruption from interleaved responses. But if your feature receives realtime pushes from other users (a shared document, a collaborative board), the local optimistic state can diverge from the broadcast state before the mutation settles. In that scenario, pair this hook with the realtime-broadcast feature rather than using it standalone, so the reconcile function has fresh server state to merge against rather than relying solely on the optimistic snapshot.

Tier: Core · Static

  1. Evaluate your use case

    Check whether optimistic mutations aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    This feature is always enabled. If you need to disable it, remove the corresponding template files after generation.

  3. Seek alternatives

    If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.

APPS USING THIS FEATURE

Every generated Goodspeed app includes optimistic mutations. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

Optimistic Mutations capability breakdown

Concrete dimensions of what the built-in optimistic mutations implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
Update timingThe optimistic state change is applied synchronously inside a setState call before the async mutation is dispatched, so the UI updates in the same render frame as the user action.Synchronous on action
Rollback strategyOn rejection, the hook restores the pre-mutation snapshot captured in the setState closure. A custom rollback function can override this to apply domain-specific undo logic.Snapshot restore or custom fn
Server reconcileAn optional reconcile callback merges the server response back into the optimistic state once the mutation resolves, handling cases where the server normalizes or enriches the payload.Optional
Concurrency guardConcurrent run() calls while a mutation is in flight are rejected with a typed error rather than silently queuing, preventing double-submit state corruption.Single-flight enforced
Error surfaceThe error value and pending flag are returned as part of the hook return tuple, making it straightforward to wire inline error messages and disabled-button states without extra local state.Built-in error + pending

COMMON QUESTIONS

Does this work on iOS Safari and web builds?

Yes. useOptimisticMutation is a pure React hook with no native dependencies. It uses only useState, useRef, and useCallback, which are available everywhere React runs: iOS, Android, and web via Expo's web target. The hook has no platform guards and no native module imports. If your app generates a web build via expo export --platform web, optimistic mutations work identically. The only caveat is that concurrent network requests on web may resolve in a different order than on native due to differences in the underlying fetch implementation, but the single-flight guard ensures the hook rejects any second call until the first settles, so out-of-order resolution is not a concern.

What happens if the user navigates away before the mutation resolves?

The hook holds state in React component state, so if the component unmounts while the mutation is in flight, the setState calls in the try/catch will be invoked on an unmounted component. React 18 no longer warns about this by default, and the state update is a no-op after unmount, meaning the rollback or reconcile will not visually fire. If your mutation has side effects beyond local UI state (for example, it updates a global store or cache), handle the unmount case in the calling component by storing results in a context or a ref that survives navigation. For purely local UI state, the default behavior is safe and requires no extra cleanup.

How is data conflict handled when two users edit the same record?

useOptimisticMutation does not resolve multi-user conflicts on its own. It manages the local state lifecycle for a single user's mutation. If another user's change arrives via a realtime subscription while a local mutation is in flight, the reconcile callback is your integration point: when the mutation resolves, reconcile receives the current state (which may have been updated by the realtime listener) and the server result, and it is your responsibility to merge them. A common pattern is to re-fetch the latest server state inside reconcile rather than merging the optimistic payload, which gives you a clean single source of truth after settlement rather than a potentially stale merged state.

Can I use this for list reordering where multiple items change at once?

Yes, and this is one of the most common uses in generated Goodspeed apps. The TState generic can be any shape, including an array of items. Your optimisticUpdate function receives the current array and the reorder arguments and returns the reordered array, which is applied immediately. The mutation function calls the server to persist the new order. If the server call fails, the hook restores the pre-reorder array from the snapshot. The single-flight guard means a user cannot kick off a second reorder while the first is persisting, which prevents the race condition where two rapid reorders produce conflicting server states. If you need to allow rapid successive reorders, debounce the run call in the component rather than loosening the concurrency guard in the hook itself.

GET IT BUILT INTO YOUR APP

Score your idea and get optimistic mutations wired in from day one