Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Optimistic vs Pessimistic UI

When you create, update, delete an entity on the UI and refresh the list immediately, chances are the newly updated entity doesn’t show up the changes. This is a classic challenge when working with systems that use Event Sourcing and CQRS (Command Query Responsibility Segregation).

  • Command: Your deleteHost request is a Command. It’s sent to the write-model to change the state of the system and publish an event (e.g., HostDeletedEvent).
  • Query: Your fetchData request is a Query. It reads from a separate read-model (the hosts database view/table).
  • Eventual Consistency: There is a delay (usually milliseconds, but it can vary) between the command succeeding and the event consumer updating the read-model.

Your UI is so fast that it’s sending the Query before the read-model has been updated, leading to the stale data problem.

Should we wait a few seconds?

No, please do not use a setTimeout to wait. This is the most important takeaway. It’s an unreliable “magic number” that will cause problems:

  • Bad UX: It forces the user to wait for an arbitrary amount of time, even if the system is fast.
  • Unreliable: If the system is under heavy load, the delay might be longer than your timeout, and the bug will reappear.
  • It’s a “code smell”: It indicates that the UI isn’t correctly handling the nature of the backend architecture.

The Professional Solutions

There are two primary, robust patterns for handling this on the UI. The best choice depends on the desired user experience.


This is the most common and user-friendly approach in modern web applications. You assume the command will succeed and update the UI immediately.

How it works:

  1. User clicks “Delete”.
  2. You immediately remove the item from your local React state. The user sees the item disappear instantly.
  3. You send the deleteHost command to the server in the background.
  4. Crucially: If the command fails for some reason (e.g., validation error, server down), you revert the UI change (add the item back) and show an error message.

This provides the best possible user experience because the UI feels instantaneous.

Here is how you would implement this in your handleDelete function:

  // Delete handler - OPTIMISTIC UI APPROACH
  const handleDelete = useCallback(async (row: MRT_Row<HostType>) => {
    if (!window.confirm(`Are you sure you want to delete host: ${row.original.subDomain}?`)) {
      return;
    }

    // Keep a copy of the current data in case we need to roll back
    const originalData = [...data];

    // 1. Optimistically update the UI
    setData(prevData => prevData.filter(host => host.hostId !== row.original.hostId));
    setRowCount(prev => prev - 1); // Also optimistically update the total count

    // 2. Send the command to the server
    const cmd = {
      host: 'lightapi.net',
      service: 'host',
      action: 'deleteHost',
      version: '0.1.0',
      data: { hostId: row.original.hostId, aggregateVersion: row.original.aggregateVersion },
    };

    try {
      const result = await apiPost({ url: '/portal/command', headers: {}, body: cmd });
      if (result.error) {
        // 3a. On failure, revert the UI and show an error
        console.error('API Error on delete:', result.error);
        alert('Failed to delete host. Please try again.'); // Or use a snackbar
        setData(originalData);
        setRowCount(originalData.length); // Revert the count
      }
      // 3b. On success, do nothing! The UI is already correct.
      // You could trigger a silent background refetch here if you want to be 100% in sync, but it's often not necessary.

    } catch (e) {
      // Also handle network errors
      console.error('Network Error on delete:', e);
      alert('Failed to delete host due to a network error.');
      setData(originalData);
      setRowCount(originalData.length);
    }
  }, [data]); // The main dependency is the 'data' for rollback.

Option 2: Pessimistic UI with State Locking (Simpler, Good UX)

This approach is more straightforward. You “lock” the UI in a loading state until you are certain the operation is complete.

How it works:

  1. User clicks “Delete”.
  2. You show a loading spinner on that specific row or disable the whole table.
  3. Send the deleteHost command.
  4. When the command API call returns a success, you then call fetchData() to get the fresh data. Because the command has completed, it’s much more likely the read model is now consistent. This is essentially what you were trying to do before.

The problem, as you noted, is that even after the command returns, the read model might still not be updated. The optimistic approach neatly sidesteps this entire timing issue. If you must stick to a pessimistic approach, the Optimistic UI is still the superior and often easier pattern to implement correctly.

To fix your current pessimistic implementation, you’d have to implement polling, which is complex:

  1. Send delete command.
  2. On success, start a setInterval to call fetchData every 2 seconds.
  3. In each fetchData response, check if the deleted item is gone.
  4. If it is, clearInterval and stop.
  5. Add a timeout to stop polling after ~10-15 seconds to prevent infinite loops.

As you can see, this is much more complicated than the optimistic update.

Recommendation

Adopt the Optimistic UI pattern (Option 1). It provides the best user experience, is resilient to timing issues caused by eventual consistency, and the implementation is clean and modern. The code provided for the optimistic handleDelete is a drop-in replacement that will solve your problem robustly.

Should you poll peroidically to sync other users’ changes

The short answer is: No, you should still avoid client-side polling for this specific use case. It’s generally the wrong tool for this problem and creates more issues than it solves. The Optimistic UI approach is still preferable, but it needs to be combined with a robust backend and potentially other real-time technologies for a complete solution.

Let’s break down why and explore the professional-grade solutions.


Why Polling is a Bad Fit Here

Your concern is valid: polling does add significant pressure, and it’s inefficient.

  1. High Network Traffic: Every active user would be sending a getHost query every few seconds. If you have 50 users on that page, that’s 10-25 queries per second just from this one component, most of which will return no new data.
  2. Database and Service Layer Load: This traffic directly translates to load on your service and database. Your SELECT query, while indexed, still consumes resources. At scale, this can become a significant performance bottleneck.
  3. Delayed UX: The user experience is still poor. A user makes a change and might have to wait up to X seconds (your polling interval) to see it reflected, which feels sluggish.
  4. Complexity: As we discussed, managing polling logic (starting, stopping, timeouts) on the client adds complexity and potential bugs.

So, while polling can eventually get you the latest data, it’s a brute-force approach with major drawbacks.


The Professional-Grade Solutions for Multi-User Environments

The key is to shift from a “pull” model (client polling) to a “push” model (server notifies the client). This is where real-time technologies shine.

Solution 1: Optimistic UI + Server-Sent Events (SSE) or WebSockets (Best for Real-Time)

This is the gold standard for collaborative applications.

How it Works:

  1. Frontend (Your Optimistic UI):

    • User A deletes a host. Their UI updates instantly (optimistic update). The deleteHost command is sent to the server.
    • User B is looking at the same list. Their screen is unchanged for now.
  2. Backend (The Magic):

    • The command handler processes the deleteHost command and publishes a HostDeletedEvent.
    • An Event Notifier Service listens for this event.
    • Upon receiving the event, this service pushes a notification to all connected clients who are interested in host updates. This is done via Server-Sent Events (SSE) or WebSockets. SSE is often simpler for server-to-client-only communication.
  3. Frontend (Receiving the Push):

    • User B’s browser receives the HostDeletedEvent push notification.
    • The React component’s event listener fires. It can do one of two things:
      • A) Smart Update (Ideal): The event payload contains the hostId that was deleted. The client simply finds that ID in its local data state and removes it. This is hyper-efficient.
      • B) Refetch (Simpler): Upon receiving any host-related event, the client triggers a fetchData() call to get the latest list. This is less efficient than a smart update but still vastly better than polling.
    • User A’s browser also receives the event. It can simply ignore it, as its UI is already up-to-date.

Why this is the best solution:

  • Real-Time: Updates are pushed instantly to all users.
  • Hyper-Efficient: No unnecessary network requests. The server and client only communicate when there’s an actual state change.
  • Scalable: A single event from the backend can update thousands of connected clients simultaneously.
  • Excellent UX: The application feels alive and collaborative.

Solution 2: Optimistic UI + Stale-While-Revalidate (SWR) / react-query with Refetch-on-Focus

This is a powerful and very easy-to-implement pattern that offers a great “80% solution” without needing a full real-time backend setup. Libraries like react-query (now TanStack Query) or Vercel’s swr are built for this.

How it Works:

  1. You replace your manual useState/useEffect/fetchData logic with the useQuery hook from react-query.
  2. User A deletes a host. You perform an optimistic update using the library’s built-in tools.
  3. User B is looking at the list. Nothing happens yet.
  4. Now, User B switches from another browser tab back to your application tab.
  5. react-query automatically detects this “window focus” event and triggers a background refetch of the data.
  6. The UI is seamlessly updated with the latest data (showing User A’s deletion).

Why this is a great solution:

  • Extremely Simple to Implement: You get this behavior for free just by using the library.
  • “Good Enough” Real-Time: Data is refreshed exactly when the user is most likely to need it (when they re-engage with the app).
  • Efficient: Avoids constant polling. It only refetches on specific, user-driven events (window focus, network reconnect, etc.).
  • Handles Caching, Loading States, etc.: These libraries solve many data-fetching headaches for you.

Recommendation & Path Forward

  1. Immediate Step: Stick with the Optimistic UI approach from my previous answer. It correctly handles the single-user eventual consistency problem, which is your most pressing issue. It’s the foundation for everything else.

  2. Next Step (Highly Recommended): Introduce a data-fetching library like TanStack Query (react-query). This will simplify your code and give you the “refetch-on-focus” behavior out of the box, largely solving the multi-user problem with minimal effort.

  3. Long-Term Goal (For True Real-Time): If your application’s core value is real-time collaboration (like a Google Doc or Figma), then plan to add a Server-Sent Events (SSE) or WebSocket layer to your backend to push updates to clients.

In summary: Avoid client-side polling. Implement the optimistic UI pattern now, and for multi-user synchronization, use a purpose-built library like react-query or a real-time backend push technology like SSE.