Optimistic UI

Optimistic UI updates give users instant feedback when they perform an action, before the server has even processed the request. This makes the application feel faster and more responsive.

How It Works

  1. You define a method that updates local state.
  2. You decorate it with @Optimistic('serverActionName').
  3. When the client calls this.serverActionName(), the framework immediately runs the optimistic handler.
  4. The request is sent to the server.
  5. If the server responds with a state update, the "true" server state overwrites your optimistic guess.
  6. If the server throws an error, the state remains unless you handle the rollback manually.

Basic Example

import { Page, Server, State, Optimistic } from '@cossackframework/core';

@Page({ transport: 'durable-object' })
class Counter extends Cossack {
    @State() count = 0;

    @Server()
    async increment() {
        // Simulate slow network/DB
        await new Promise(r => setTimeout(r, 500));
        this.count++;
    }

    // This runs immediately on the client when this.increment() is called
    @Optimistic('increment')
    applyOptimisticIncrement() {
        this.count++;
    }
}

Advanced: Stable Optimistic UI Pattern

When users perform actions rapidly (e.g., clicking a button multiple times), naive optimistic updates can cause "flapping" in the UI.

The Problem (Flapping):

  1. User clicks twice. Local count: 0 -> 1 -> 2.
  2. Server processes 1st click. Returns count: 1. UI resets to 1.
  3. Server processes 2nd click. Returns count: 2. UI jumps to 2. Result: 0 -> 1 -> 2 -> 1 -> 2.

The Solution: Use this.loading[methodName] combined with a separate @ClientState for the optimistic value and a @Computed property for the display value.

@Page({ transport: 'durable-object' })
export class OptimisticCounter extends Cossack {
    @State() count = 0;           // True server state
    @ClientState() optCount = 0;  // Local optimistic state

    @Computed()
    get displayCount() {
        // If we have pending requests, show our local guess.
        // Otherwise, show the authoritative server state.
        return (this.loading['increment'] > 0) ? this.optCount : this.count;
    }

    @Server()
    async increment() {
        await new Promise(r => setTimeout(r, 500));
        this.count++;
    }

    @Optimistic('increment')
    applyOptimistic() {
        // If starting a new chain of requests, sync with server state first
        if (!this.loading['increment']) {
            this.optCount = this.count;
        }
        this.optCount++;
    }

    render() {
        return html`Count: ${this.displayCount}`;
    }
}