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
deleteHostrequest 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
fetchDatarequest is a Query. It reads from a separate read-model (thehostsdatabase 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.
Option 1: Optimistic UI (Recommended for Best UX)
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:
- User clicks “Delete”.
- You immediately remove the item from your local React state. The user sees the item disappear instantly.
- You send the
deleteHostcommand to the server in the background. - 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:
- User clicks “Delete”.
- You show a loading spinner on that specific row or disable the whole table.
- Send the
deleteHostcommand. - 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:
- Send delete command.
- On success, start a
setIntervalto callfetchDataevery 2 seconds. - In each
fetchDataresponse, check if the deleted item is gone. - If it is,
clearIntervaland stop. - 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.
- High Network Traffic: Every active user would be sending a
getHostquery 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. - Database and Service Layer Load: This traffic directly translates to load on your service and database. Your
SELECTquery, while indexed, still consumes resources. At scale, this can become a significant performance bottleneck. - Delayed UX: The user experience is still poor. A user makes a change and might have to wait up to
Xseconds (your polling interval) to see it reflected, which feels sluggish. - 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:
-
Frontend (Your Optimistic UI):
- User A deletes a host. Their UI updates instantly (optimistic update). The
deleteHostcommand is sent to the server. - User B is looking at the same list. Their screen is unchanged for now.
- User A deletes a host. Their UI updates instantly (optimistic update). The
-
Backend (The Magic):
- The command handler processes the
deleteHostcommand and publishes aHostDeletedEvent. - 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
hostupdates. This is done via Server-Sent Events (SSE) or WebSockets. SSE is often simpler for server-to-client-only communication.
- The command handler processes the
-
Frontend (Receiving the Push):
- User B’s browser receives the
HostDeletedEventpush notification. - The React component’s event listener fires. It can do one of two things:
- A) Smart Update (Ideal): The event payload contains the
hostIdthat was deleted. The client simply finds that ID in its localdatastate 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.
- A) Smart Update (Ideal): The event payload contains the
- User A’s browser also receives the event. It can simply ignore it, as its UI is already up-to-date.
- User B’s browser receives the
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:
- You replace your manual
useState/useEffect/fetchDatalogic with theuseQueryhook fromreact-query. - User A deletes a host. You perform an optimistic update using the library’s built-in tools.
- User B is looking at the list. Nothing happens yet.
- Now, User B switches from another browser tab back to your application tab.
react-queryautomatically detects this “window focus” event and triggers a background refetch of the data.- 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
-
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.
-
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. -
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.