Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 81

Optimistic UI: Enhancing User Experience

Recommended resources

Hello! In our last lesson, we successfully integrated an external API into our "My Reading List" application, allowing you to search for books and add them to a personal list using a centralized Pinia store. This was a huge step in making our app dynamic and data-driven.

However, you might have noticed a slight delay when you click "Add to List." The app waits for the network request to complete before confirming the addition. While functional, this can make the application feel a bit sluggish.

Today, we'll address this by implementing a powerful pattern to enhance user experience: optimistic UI updates. You will learn how to make your application feel instantaneous by updating the interface before the server confirms an action, and how to handle cases where the server request might fail.


1. What are Optimistic UI Updates?

The traditional approach to handling user actions that require a server roundtrip is sequential and cautious:

  1. User clicks a button (e.g., "Like," "Add to list").
  2. The UI shows a loading spinner.
  3. A request is sent to the server.
  4. The server processes the request and sends a response.
  5. The UI finally updates based on the response.

This process is safe but introduces a noticeable delay. Optimistic UI flips this on its head:

  1. User clicks a button.
  2. The UI updates immediately, assuming the request will succeed.
  3. A request is sent to the server in the background.
  4. If it succeeds, nothing more needs to be done. The user already sees the new state.
  5. If it fails, the UI change is reverted (rolled back), and the user is notified.

This approach makes the application feel incredibly responsive, as if the operation were purely local.

Frontend System Design Essentials: Optimistic updates

To see this concept in action, let's watch the introduction to this video from the I Code It channel. It clearly explains and demonstrates the difference between a traditional loading state and an optimistic update.

Watch from the beginning until 01:17. Pay attention to how an optimistic update provides immediate feedback, making the application feel faster.

This diagram illustrates the two possible outcomes of an optimistic update: success and failure. In both cases, the UI state changes instantly for the user.

Optimistic UI Flow: Success vs. Failure
This diagram shows the flow of an optimistic update. The UI updates to an 'Optimistic' state immediately after a request is made. If the server confirms success, the state is finalized. If the server reports failure, the UI reverts to its original state.


2. The Anatomy of a Robust Optimistic Update

Simply updating the UI isn't enough. A robust implementation requires a clear, predictable process to handle both success and, crucially, failure. The article "Optimistic Updates and Pinia Colada" explains the core hooks and concepts used in modern data-fetching libraries, which we can adapt for our own Pinia store.

Optimistic Updates and Pinia Colada - Vue School Articles

This Vue School article, while using a library called Pinia Colada, breaks down the essential steps for any optimistic update: updating the cache, handling success, and rolling back on error. The principles are universal and directly applicable to our project.

Please read the following sections: 'What Are Optimistic Updates?', 'Key Concepts' (focusing on the purpose of onMutate, setQueryData, and onSettled), and 'Handling Rollbacks'. Understand the logic of saving the old data before the mutation and using it to restore state if an error occurs.

Based on that reading, here is the full lifecycle of an optimistic update that we need to implement:

  1. Snapshot the current state: Before making any changes, get a copy of the data that you are about to modify. This is your safety net for the rollback.
  2. Apply the optimistic change: Modify your local state (in our case, the Pinia store) to reflect the desired outcome. The UI will reactively update.
  3. Perform the async action: Send the actual API request to the server.
  4. On success: The operation is complete. You might want to "reconcile" your local state with the final data returned from the server (e.g., if the server assigns a permanent ID to a new item).
  5. On failure (the catch block): This is the most critical part. Use the snapshot from Step 1 to restore the state to how it was before the optimistic change. You should also inform the user that their action failed.

Ignoring the rollback mechanism is a common mistake that can lead to a buggy and confusing user experience. As noted in the article "How do you manage Vue.js state without races or slowdowns?", one of the most common errors in state management is "Updating UI optimistically with no snapshot/rollback path."



3. Your Task: Optimistically Adding a Book

Let's refactor the addBookToReadingList action in our bookStore to be optimistic. First, we need to simulate a real API call that can succeed or fail.

Step 1: Simulate an API Service

Update your src/services/bookService.js to include a new, simulated addBook method.

// src/services/bookService.js

import axios from 'axios';

// Create an Axios instance... (from previous lesson)
const apiClient = axios.create({
  baseURL: 'https://openlibrary.org',
  headers: {
    'Content-Type': 'application/json'
  }
});

const FAKE_API_DELAY = 1500; // 1.5 seconds

export default {
  searchBooks(query) {
    // ... (from previous lesson)
    return apiClient.get('/search.json', {
      params: {
        q: query
      }
    });
  },

  // NEW: Simulate adding a book to a remote database
  addBook(book) {
    console.log('API Call: Attempting to add book...');
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // We'll simulate a 20% chance of failure for demonstration
        if (Math.random() < 0.8) {
          // On success, return the book, maybe with a server-generated ID
          console.log(`API Call: Successfully added "${book.title}"`);
          resolve({ ...book, serverId: `server_${Date.now()}` });
        } else {
          // On failure, reject the promise with an error
          console.error(`API Call: Failed to add "${book.title}"`);
          reject(new Error('Server error: Could not save the book.'));
        }
      }, FAKE_API_DELAY);
    });
  }
};

Step 2: Implement the Optimistic Action in bookStore.js

Now, let's rewrite the addBookToReadingList action in src/stores/bookStore.js to use our new service and follow the optimistic update pattern.

// src/stores/bookStore.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
import bookService from '@/services/bookService';

export const useBookStore = defineStore('books', () => {
  // === STATE === (from previous lesson)
  const readingList = ref([]);
  const searchResults = ref([]);
  const isLoading = ref(false);
  const error = ref(null);

  // === ACTIONS ===

  // searchBooks action... (from previous lesson)
  async function searchBooks(query) { /* ... */ }

  // REFACTORED ACTION: Add a book to the list optimistically
  async function addBookToReadingList(book) {
    // Prevent adding duplicates
    if (readingList.value.find(item => item.key === book.key)) {
      console.log('Book is already in the reading list.');
      return;
    }

    // Clear any previous errors
    error.value = null;

    // 1. Apply the optimistic update
    // We add the book to the UI immediately.
    readingList.value.push(book);

    try {
      // 2. Perform the async action
      const savedBook = await bookService.addBook(book);

      // 3. On success, reconcile the state.
      // In a real app, you might update the temporary book with the server version.
      // Here, we can just log the success.
      console.log('Optimistic update successful. Server confirmed:', savedBook);
      // If the server returned a different object, you would find and replace it here.
      
    } catch (err) {
      // 4. On failure, roll back the change.
      console.error('Optimistic update failed. Rolling back.', err.message);
      
      // Remove the book that we optimistically added.
      readingList.value = readingList.value.filter(b => b.key !== book.key);

      // Notify the user
      error.value = `Failed to add "${book.title}". Please try again.`;
      // We can clear this error after a few seconds
      setTimeout(() => { error.value = null; }, 5000);
    }
  }

  return {
    readingList,
    searchResults,
    isLoading,
    error,
    searchBooks,
    addBookToReadingList
  };
});

Step 3: Display the Error in the UI

To complete the feedback loop, we need to show the user when an error occurs. Let's add an error message display to our DashboardView.vue.

<!-- src/views/DashboardView.vue -->
<script setup>
import { storeToRefs } from 'pinia';
import { useBookStore } from '@/stores/bookStore';

const bookStore = useBookStore();
// Make sure to destructure 'error' from the store
const { readingList, error } = storeToRefs(bookStore);
</script>

<template>
  <div>
    <h1>My Reading List</h1>

    <!-- NEW: Error display -->
    <div v-if="error" style="color: red; margin-bottom: 1rem; border: 1px solid red; padding: 0.5rem;">
      <p>{{ error }}</p>
    </div>

    <ul v-if="readingList.length > 0">
      <li v-for="book in readingList" :key="book.key">
        {{ book.title }}
      </li>
    </ul>
    <p v-else>Your reading list is empty. Go to the search page to add some books!</p>
  </div>
</template>

Verify your work:

  1. Run the application.
  2. Search for a book and click "Add to List." Notice it appears on your dashboard instantly.
  3. Because our simulation fails 20% of the time, keep adding books. When a failure occurs, you will see the book instantly appear, then disappear after ~1.5 seconds, and an error message will show up on the dashboard. This is the rollback in action!


4. When Should (and Shouldn't) You Use Optimistic Updates?

This technique is powerful, but it's not a silver bullet. You must choose wisely when to apply it.

When NOT to Use Optimistic Updates - Vue School Articles

The Vue School article concludes with important considerations on when optimistic updates are not appropriate. This is crucial for making sound architectural decisions.

Read the final section titled 'When NOT to Use Optimistic Updates' to understand the risks and appropriate use cases.

In summary:

  • Good for: High-frequency, low-risk actions where success is the likely outcome. Examples include liking a post, adding an item to a to-do list, starring a repository, or submitting a comment.
  • Bad for: Critical, irreversible operations. Never use optimistic updates for actions like financial transactions, deleting an account, or any process where server-side validation and transformation are complex and unpredictable. For these, a traditional loading state is safer and more honest.


Conclusion

Congratulations! You have implemented an advanced user experience pattern that sets professional applications apart from basic ones. By making your UI feel instantaneous, you create a more fluid and engaging experience for your users.

Key Takeaways:

  • Optimistic UI updates the interface immediately, assuming an operation will succeed, providing a feeling of instant responsiveness.
  • The core of a robust implementation is the rollback mechanism: if the server request fails, the UI must be reverted to its previous state.
  • The implementation flow is: optimistically update state -> send API request -> handle success or rollback on failure.
  • This pattern is best for low-risk, high-frequency actions and should be avoided for critical or irreversible operations.

Next Lesson:
Our application's functionality is now solid and feels fast. In the next lesson, we will shift our focus to the visual presentation. You will learn how to ensure the entire application is fully responsive using Tailwind CSS, making it look great and work perfectly on devices of all sizes, from mobile phones to desktop monitors.