Skip to content

Optimistic Update ​

Goal ​

Update the UI immediately on user action, then confirm with the server. If the server call fails, rollback automatically.

Using optimistic() ​

The optimistic() helper handles this pattern with two functions:

  1. apply — sync function that mutates state immediately
  2. confirm — async function that calls the server

If confirm throws, the state is automatically restored to its pre-apply snapshot using structuredClone.

ts
import { createStore, http, optimistic } from '@ngstato/core'

export const taskStore = createStore({
  tasks: [] as Task[],
  error: null as string | null,

  actions: {
    // Delete — instant removal, rollback if server fails
    deleteTask: optimistic(
      (state, id: number) => {
        state.tasks = state.tasks.filter(t => t.id !== id)
      },
      async (state, id: number) => {
        await http.delete(`/tasks/${id}`)
      }
    ),

    // Toggle complete — instant flip, rollback if server fails
    toggleComplete: optimistic(
      (state, id: number) => {
        state.tasks = state.tasks.map(t =>
          t.id === id ? { ...t, done: !t.done } : t
        )
      },
      async (state, id: number) => {
        const task = state.tasks.find(t => t.id === id)
        await http.patch(`/tasks/${id}`, { done: task?.done })
      }
    ),

    // Reorder — instant reorder, rollback if server fails
    moveTask: optimistic(
      (state, fromIndex: number, toIndex: number) => {
        const tasks = [...state.tasks]
        const [moved] = tasks.splice(fromIndex, 1)
        tasks.splice(toIndex, 0, moved)
        state.tasks = tasks
      },
      async (state, fromIndex: number, toIndex: number) => {
        await http.put('/tasks/reorder', {
          ids: state.tasks.map(t => t.id)
        })
      }
    )
  },

  hooks: {
    onError: (err, name) => {
      console.warn(`[TaskStore] ${name} rolled back:`, err.message)
    }
  }
})

How rollback works ​

User clicks "Delete"
  │
  ├── 1. structuredClone(state) → deep snapshot saved
  ├── 2. apply(state, id) → task removed from UI instantly
  ├── 3. confirm(state, id) → http.delete('/tasks/123')
  │
  ├── Success? → done, snapshot discarded
  └── Failure? → Object.assign(state, snapshot) → state restored
                  → onError hook fires

Deep copy

optimistic() uses structuredClone for the snapshot, which handles nested objects and arrays correctly. Regular spread ({ ...state }) would only copy the first level.

With entity adapter ​

ts
const adapter = createEntityAdapter<Task>({ selectId: (t) => t.id })

actions: {
  deleteTask: optimistic(
    (state, id: number) => {
      adapter.removeOne(state, id)
    },
    async (state, id: number) => {
      await http.delete(`/tasks/${id}`)
    }
  )
}

Error feedback ​

Show a temporary "undo" message when rollback happens:

ts
@Component({
  template: `
    @if (store.error()) {
      <div class="rollback-toast">
        Action failed — changes reverted.
        <button (click)="store.clearError()">Dismiss</button>
      </div>
    }
  `
})

When to use optimistic updates ​

ScenarioRecommended?
Delete item from list✅ Yes — instant feedback
Toggle boolean (like/done)✅ Yes — instant toggle
Drag-and-drop reorder✅ Yes — smooth UX
Create new item⚠️ Maybe — need server-generated ID
Complex multi-field edit❌ No — wait for server response
Financial transactions❌ No — must confirm first

Released under the MIT License.