Published on

Mastering Optimistic Updates in React with React Query: A Practical Guide

Authors
Buy Me A Coffee

Mastering Optimistic Updates in React with React Query: A Practical Guide

Tired of slow UI updates in your React applications when handling server-side operations? Optimistic updates, especially with a library like React Query, provide a powerful solution. This technique immediately updates your UI, creating a faster, more responsive user experience. This article delves into implementing optimistic updates in React using React Query, covering the core concepts, practical code examples, and strategies for graceful error handling and rollbacks.

What are Optimistic Updates?

Optimistic updates is a UI pattern where you update the user interface immediately as if a server-side operation (like creating, updating, or deleting data) has already succeeded, before receiving confirmation from the server. This provides instant feedback, significantly enhancing the perceived performance of your application.

Think of it like this: you click the "like" button on a social media post. Instead of waiting for the server to respond (which might take a second or two, especially on a slow connection), the like count immediately increments in your UI. This immediate update creates a much more responsive feel. However, a potential trade-off of this approach is that the UI can briefly display data that doesn't match the server's state if the server operation fails. Therefore, you must be prepared to handle errors and revert the changes.

Benefits of Optimistic Updates:

  • Improved Perceived Performance: Users experience instant feedback, leading to a more fluid and responsive application.
  • Enhanced User Experience: A snappy UI makes users feel more engaged and satisfied.
  • Reduced Waiting Time: Eliminates the feeling of lag that can occur while waiting for server responses.
  • Improved perceived performance even with intermittent network connectivity.

Setting Up React Query

React Query (now TanStack Query) is a powerful data-fetching and caching library for React. Its ease of integration makes it an excellent choice for managing server state.

Installation:

npm install @tanstack/react-query
# or
yarn add @tanstack/react-query

Basic Configuration:

You configure React Query by wrapping your application with the QueryClientProvider and creating a QueryClient instance. The QueryClientProvider makes the QueryClient available to all components within your application, while the QueryClient manages the caching and data fetching behavior.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Your application components */}
    </QueryClientProvider>
  );
}

export default App;

Now you're ready to leverage React Query's features, including its excellent support for mutations and optimistic updates. For more detailed setup instructions, refer to the TanStack Query documentation.

Implementing a Basic Mutation (Without Optimistic Updates)

Let's start with a simple example: adding a to-do item to a list. We'll use useMutation to handle the API request, but initially, we won't implement optimistic updates.

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation(
    (newTodo) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      })
        .then((res) => {
          if (!res.ok) {
            throw new Error(`HTTP error! status: ${res.status}`);
          }
          return res.json();
        })
        .catch((error) => {
          // Handle network errors or other exceptions
          console.error('Fetch error:', error);
          throw error; // Re-throw to be caught by useMutation
        }),
    {
      onSuccess: () => {
        // Invalidate the todos query to refetch the data
        queryClient.invalidateQueries({ queryKey: ['todos'] }); // Refetch to update the UI
      },
    }
  );

  const handleSubmit = async (event) => {
    event.preventDefault();
    const todoText = event.target.todo.value;
    try {
      await mutation.mutate({ text: todoText });
      event.target.todo.value = ''; // Clear the input after successful mutation
    } catch (error) {
      // Handle mutation errors (e.g., display an error message) - already handled in the fetch
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" id="todo" placeholder="Add a todo" />
      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adding...' : 'Add Todo'}
      </button>
      {mutation.isError ? (
        <p>Error: {mutation.error?.message || 'Failed to add todo.'}</p>
      ) : null}
    </form>
  );
}

export default AddTodo;

Explanation:

  1. useMutation Hook: This hook handles the mutation logic (in this case, adding a new to-do).
  2. Mutation Function: The first argument to useMutation is an asynchronous function that performs the actual API request using fetch. It's important to handle potential errors during the fetch. If the server responds with an error, or if a network error occurs, the error is thrown and caught by the useMutation hook.
  3. onSuccess Callback: After the mutation succeeds, the onSuccess callback is executed. Here, we invalidate the todos query using queryClient.invalidateQueries({ queryKey: ['todos'] }). This tells React Query to refetch the data, ensuring our UI is up-to-date and reflects the changes on the server. The UI will then automatically update to reflect the new to-do item.
  4. handleSubmit Function: This function is called when the form is submitted. It retrieves the to-do text from the input field and calls mutation.mutate to trigger the mutation. It also handles errors that may occur during the mutation process, such as network errors.
  5. Loading and Error States: The component displays loading and error messages based on the mutation.isLoading and mutation.isError states. The button is disabled while loading.

With this setup, the user clicks "Add Todo", the request is sent to the server, and then the UI updates after the server responds. This delay, however small, can be noticeable.

Adding Optimistic Updates with onMutate

Now let's enhance the example with optimistic updates. We'll use the onMutate option in useMutation to update the local cache before the server request completes.

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation(
    (newTodo) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      })
        .then((res) => {
          if (!res.ok) {
            throw new Error(`HTTP error! status: ${res.status}`);
          }
          return res.json();
        })
        .catch((error) => {
          console.error('Fetch error:', error);
          throw error;
        }),
    {
      onMutate: async (newTodo) => {
        // 1. Cancel any outgoing refetches (so they don't overwrite our optimistic update)
        await queryClient.cancelQueries({ queryKey: ['todos'] });

        // 2. Snapshot the previous value
        const previousTodos = queryClient.getQueryData({ queryKey: ['todos'] });

        // 3. Optimistically update to the new value
        queryClient.setQueryData({ queryKey: ['todos'] }, (oldTodos) => [
          ...(oldTodos || []), // Spread operator to add the new todo without mutating the old array
          { id: Date.now(), ...newTodo, temp: true }, // Add a temporary ID until we get the real one
        ]);

        // 4. Return a context object with the snapshotted value
        return { previousTodos };
      },
      onError: (err, newTodo, context) => {
        // If the mutation fails, roll back to the previous value
        console.error('Failed to add todo:', err);
        queryClient.setQueryData({ queryKey: ['todos'] }, context.previousTodos);
        // Provide user feedback.  Consider a more sophisticated approach:
        alert(`Failed to add todo: ${err?.message || 'Unknown error'}. Please try again.`); // Or display an error message near the input
      },
      onSettled: () => {
        // Always refetch after error or success:
        queryClient.invalidateQueries({ queryKey: ['todos'] });
      },
    }
  );

  const handleSubmit = async (event) => {
    event.preventDefault();
    const todoText = event.target.todo.value;
    try {
      await mutation.mutate({ text: todoText });
      event.target.todo.value = ''; // Clear the input after successful mutation
    } catch (error) {
      // Errors are already handled in the mutation function and onError callback
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" id="todo" placeholder="Add a todo" />
      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adding...' : 'Add Todo'}
      </button>
      {mutation.isError ? (
        <p>Error: {mutation.error?.message || 'Failed to add todo.'}</p>
      ) : null}
    </form>
  );
}

export default AddTodo;

Key Changes:

  • onMutate Callback: This asynchronous function executes before the mutation function.
    1. queryClient.cancelQueries({ queryKey: ['todos'] }): This is crucial to prevent race conditions. If the user adds a to-do and, while that request is pending, React Query decides to refetch the to-dos (perhaps due to a background update or the user navigating back to the page), the UI could briefly display an outdated state. Canceling the refetch ensures that the optimistic update is not overwritten by stale data.
    2. queryClient.getQueryData({ queryKey: ['todos'] }): Retrieves the current data for the 'todos' query (our current list of to-dos). This is necessary to store the previous state so that we can revert to it if the mutation fails.
    3. queryClient.setQueryData({ queryKey: ['todos'] }, ...): Updates the cache with the new to-do item immediately. This is the core of the optimistic update. We add a temporary ID (temp: true) to the new to-do. This allows us to visually distinguish the optimistic update from the server's response. We use the spread operator (...) to add the new to-do item to the existing list without directly mutating the original array, which is a best practice for React state management.
    4. return { previousTodos }: Returns a context object containing the previous value of the query data. This is essential for rolling back the update in the onError callback if the mutation fails.
  • onError Callback: This function is called if the mutation encounters an error.
    • queryClient.setQueryData({ queryKey: ['todos'] }, context.previousTodos): Reverts the cache to the previous value (the value before the optimistic update). This ensures data consistency by removing the optimistically added to-do if the server request fails.
    • We also include basic error logging to the console and provide user feedback through an alert. A more user-friendly approach would be to display an error message near the input field or use a notification system (e.g., a toast message). This gives the user immediate feedback about the failure.
  • onSettled Callback: This function is called regardless of whether the mutation succeeds or fails.
    • queryClient.invalidateQueries({ queryKey: ['todos'] }): This is critically important. It refetches the todos query. After a successful mutation, this will update the optimistic update with the server's actual data, including the real ID assigned by the server and removing any temporary flags. If the mutation fails, invalidateQueries will still run, which will refetch the original data, reverting the optimistic change. This ensures synchronization between the client and the server data, regardless of the outcome of the mutation.

Diagram of the Optimistic Update Flow:

sequenceDiagram
    participant User
    participant React Component
    participant React Query Cache
    participant Server

    User->>React Component: Triggers Mutation (e.g., Add Todo)
    React Component->>React Query Cache: onMutate (Optimistic Update)
    React Query Cache->>React Component: Immediate UI Update
    React Component->>Server: Mutation Request (e.g., POST /api/todos)
    alt Success
        Server->>React Component: Success Response
        React Component->>React Query Cache: onSettled -> invalidateQueries
        React Query Cache->>Server: Refetch Data
        Server->>React Query Cache: Updated Data
        React Query Cache->>React Component: UI Update (with server data)
    else Failure
        Server->>React Component: Error Response
        React Component->>React Query Cache: onError (Rollback)
        React Query Cache->>React Component: UI Reverted
    end

Handling Errors and Rollbacks

The onError callback is your safety net. If the server rejects the mutation, you must revert the optimistic update to maintain data consistency. The context object returned from onMutate provides the data needed to perform the rollback.

The example above uses a simple alert to notify the user of a failure. However, a better user experience involves providing more informative feedback. Consider displaying an error message near the input field, using a notification system (e.g., a toast message), or even implementing retry logic.

To improve the robustness of error handling, you should also:

  • Log Errors: Log errors to the console for debugging purposes.
  • Handle Network Errors: Use try...catch blocks around your API calls to handle network errors gracefully.

Using onSettled for Final Cleanup

The onSettled callback is crucial for actions that need to occur regardless of the mutation's outcome. It always runs after the mutation has completed (successfully or unsuccessfully). In our example, we use onSettled to call invalidateQueries. This is essential because it ensures that the cache is synchronized with the server after the mutation, regardless of success or failure. When the mutation succeeds, invalidateQueries updates the optimistic data with the server's response, including the real ID and removing any temporary flags. If the mutation fails, it ensures that the UI reflects the correct state by refetching the original data.

Advanced Considerations

  • Optimistic Updates with Pagination or Infinite Scrolling: These scenarios require careful handling of the cache. The primary challenge is determining where to insert the new item in the cached list. For instance, if a user adds a to-do to the first page of a paginated list, you'll need to:

    1. Update the cache for the specific page where the new to-do belongs.
    2. If the new to-do causes the first page to exceed its item limit, you might need to update the cache for subsequent pages as well.
    3. Consider the possibility that the newly added item should appear on a different page.

    You might use the updater function with setQueryData to precisely control how the cache is updated. Example:

    queryClient.setQueryData({ queryKey: ['todos', { page: 1 }] }, (oldData) => {
      if (oldData) {
        return {
          ...oldData,
          data: [newItem, ...oldData.data], // Assuming the new item should be added to the beginning
        };
      }
      return { data: [newItem], ...otherInitialData };
    });
    

    For more information and examples of optimistic updates with paginated data, refer to the React Query documentation on optimistic updates and search for articles on optimistic updates with pagination or infinite scrolling.

  • Conflict Resolution: If multiple users can update the same data, conflicts can arise. Several strategies exist for conflict resolution:

    • Last-Write-Wins: The simplest approach; the last update overwrites previous ones. This is often acceptable for simple data.
    • Merging: The server attempts to merge the changes from different users. This is complex but can preserve more data.
    • Prompting the User: The server detects a conflict (e.g., using timestamps or version numbers) and prompts the user to resolve it, usually by showing the conflicting versions and allowing the user to choose.
    • Server-Side Error Codes: The server can return specific error codes (e.g., 409 Conflict) to indicate that the data has been modified since the user last retrieved it. This allows the client to refetch the latest version and prompt the user to resolve the conflict.
  • Complex Data Structures: Optimistically updating nested or complex data structures can be challenging. Consider using immutable data structures (e.g., using libraries like Immer or the spread operator) to simplify the process and avoid unexpected side effects. Immutability helps ensure that you're not accidentally modifying the original data and makes it easier to revert changes during rollbacks.

Conclusion and Next Steps

Optimistic updates are a valuable tool for enhancing the user experience of your React applications. By leveraging React Query's useMutation hook and its onMutate, onError, and onSettled options, you can implement optimistic updates with ease and confidence. Remember to handle errors and rollbacks gracefully to maintain data consistency.

Next Steps:

By mastering optimistic updates, you can create more responsive, engaging, and user-friendly React applications.