Optimistic Updates with React Query: Step-by-Step Tutorial

Okay, here’s a comprehensive article on Optimistic Updates with React Query, aiming for approximately 5000 words. This will cover the concepts, implementation details, and best practices, with extensive code examples.

Optimistic Updates with React Query: A Step-by-Step Tutorial

Introduction

In modern web applications, providing a seamless and responsive user experience is paramount. Users expect immediate feedback, even when performing actions that require communication with a server (like creating, updating, or deleting data). Waiting for a server response before reflecting changes in the UI can lead to a sluggish feel, especially on slower network connections. This is where optimistic updates come into play.

Optimistic updates are a technique where the UI is updated immediately as if the server operation has already succeeded, before the actual server request is completed. This creates the perception of instant action. If the server operation succeeds, the UI remains in its updated state. If the server operation fails, the UI is reverted to its previous state, and an appropriate error message is displayed.

React Query, a powerful data fetching and state management library for React applications, provides excellent built-in support for optimistic updates. This tutorial will guide you through implementing optimistic updates with React Query, covering various scenarios and best practices.

Why Use Optimistic Updates?

  • Improved Perceived Performance: The most significant benefit is the improved perceived performance. Users see changes instantly, making the application feel faster and more responsive.
  • Better User Experience: A responsive UI leads to a more engaging and satisfying user experience. Users don’t have to wait for spinners or loading indicators for every action.
  • Reduced Server Load (Potentially): While not the primary goal, optimistic updates can reduce server load in specific scenarios. If a user makes multiple rapid changes, only the final state needs to be sent to the server (with careful implementation of debouncing or throttling).
  • Offline-First Approach (Advanced): Optimistic updates can be a building block for more advanced offline-first applications. The UI updates immediately, and the changes are queued for synchronization with the server when the connection is restored.

Prerequisites

Before we dive into the implementation, make sure you have the following:

  • Node.js and npm (or yarn) installed: You’ll need these to manage your project dependencies.
  • A React project set up: You can use Create React App or any other React setup you prefer.
  • Basic understanding of React: Familiarity with components, hooks (especially useState and useEffect), and JSX is essential.
  • Basic understanding of React Query: You should know how to fetch data using useQuery. If you’re new to React Query, I recommend going through their official documentation first.

Installation

To use React Query, install it via npm or yarn:

“`bash
npm install react-query

or

yarn add react-query
“`

Setting Up React Query

You need to wrap your application with QueryClientProvider and create a QueryClient instance. This usually happens in your application’s root component (e.g., App.js or index.js):

“`javascript
import { QueryClient, QueryClientProvider } from ‘react-query’;
import ReactDOM from ‘react-dom/client’;
import App from ‘./App’;

const queryClient = new QueryClient();

const root = ReactDOM.createRoot(document.getElementById(‘root’));
root.render(



);
“`

Core Concepts of Optimistic Updates with React Query

React Query provides a structured way to implement optimistic updates using the useMutation hook. Here’s a breakdown of the key concepts:

  1. useMutation: This hook is used for performing mutations (create, update, delete operations). It provides functions and state related to the mutation process.

  2. onMutate: This is the crucial callback function for optimistic updates. It runs before the actual mutation function is executed. Inside onMutate, you:

    • Cancel any outgoing refetches: This prevents potential conflicts between the optimistic update and a background refetch.
    • Snapshot the previous data: You store the current data from the cache before applying the optimistic update. This is essential for rolling back the changes if the mutation fails.
    • Apply the optimistic update: You update the cache with the new data as if the server operation had succeeded. React Query will automatically re-render any components using the updated data.
    • Return a context object: This context object contains the snapshot of the previous data. This object will be passed to the onError and onSettled callbacks.
  3. onError: This callback function runs if the mutation function throws an error (i.e., the server operation fails). Inside onError, you:

    • Roll back the optimistic update: You use the snapshot from the context object (returned by onMutate) to revert the cache to its previous state.
    • Handle the error: You might display an error message to the user or perform other error-handling logic.
  4. onSettled: This callback function runs after the mutation is complete, regardless of whether it succeeded or failed. Inside onSettled, you:

    • Invalidate the Query: It is the best practice to refetch, refresh the cache with the most up-to-date data from the server. This handles a variety of edge cases.
    • Perform any cleanup: You might clear loading states or perform other cleanup tasks.
  5. queryClient.setQueryData: This function is used to directly update the data in the React Query cache. You use this in onMutate to apply the optimistic update and in onError to roll back the changes.

  6. queryClient.getQueryData: This function is used to get current data in the React Query cache. You will use it to create the snapshot before applying the optimistic update.

  7. queryClient.invalidateQueries: This function is used to tell that the Query is old, and needs to be re-fetched.

Step-by-Step Implementation: A To-Do List Example

Let’s build a simple to-do list application to demonstrate optimistic updates. We’ll focus on adding a new to-do item optimistically.

1. Mock API (for simplicity)

For this tutorial, we’ll use a mock API to simulate server interactions. This avoids the need for a real backend. Create a file named api.js:

“`javascript
// api.js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

let todos = [
{ id: 1, text: ‘Learn React Query’, completed: false },
{ id: 2, text: ‘Build a to-do app’, completed: true },
];

let nextId = 3;

export const getTodos = async () => {
await sleep(500); // Simulate network latency
return […todos]; // Return a copy to avoid direct mutation
};

export const addTodo = async (newTodo) => {
await sleep(500); // Simulate network latency
const todo = { …newTodo, id: nextId++ };
todos = […todos, todo];
return todo;
};

export const updateTodo = async (updatedTodo) => {
await sleep(500);
todos = todos.map((todo) =>
todo.id === updatedTodo.id ? updatedTodo : todo
);
return updatedTodo;
};

export const deleteTodo = async (id) => {
await sleep(500);
const todoToDelete = todos.find(todo => todo.id === id);
todos = todos.filter((todo) => todo.id !== id);
return todoToDelete;
};

// Simulate an error for testing
export const addTodoWithError = async (newTodo) => {
await sleep(500);
throw new Error(‘Failed to add to-do!’);
};

“`

2. To-Do List Component (TodoList.js)

“`javascript
// TodoList.js
import React, { useState } from ‘react’;
import { useQuery, useMutation, useQueryClient } from ‘react-query’;
import { getTodos, addTodo, addTodoWithError, updateTodo, deleteTodo } from ‘./api’;

function TodoList() {
const [newTodoText, setNewTodoText] = useState(”);
const [errorMode, setErrorMode] = useState(false); // Toggle for simulating errors

const queryClient = useQueryClient();

// Fetch to-dos
const {
data: todos,
isLoading,
isError,
error,
} = useQuery(‘todos’, getTodos);

// Add to-do mutation (optimistic update)
const addTodoMutation = useMutation(
errorMode ? addTodoWithError : addTodo, // Use the error-throwing function if errorMode is true
{
onMutate: async (newTodo) => {
// Cancel any outgoing refetches (to prevent them from
// interfering with our optimistic update)
await queryClient.cancelQueries(‘todos’);

    // Snapshot the previous to-dos
    const previousTodos = queryClient.getQueryData('todos');

    // Optimistically update to the new value
    queryClient.setQueryData('todos', (old) => [...old, { ...newTodo, id: Date.now(), completed: false }]);

    // Return a context object with the snapshotted value
    return { previousTodos };
  },
  // If the mutation fails, use the context returned from onMutate to roll back
  onError: (err, newTodo, context) => {
      console.error("addTodoMutation", err);
    queryClient.setQueryData('todos', context.previousTodos);
      alert(err.message) // Not best practice, but good for demonstration
  },
  // Always refetch after error or success:
  onSettled: () => {
    queryClient.invalidateQueries('todos');
  },
}

);

// Update to-do mutation (optimistic update)
const updateTodoMutation = useMutation(updateTodo, {
    onMutate: async (updatedTodo) => {
        await queryClient.cancelQueries('todos');
        const previousTodos = queryClient.getQueryData('todos');

        queryClient.setQueryData('todos', (old) =>
            old.map((todo) =>
                todo.id === updatedTodo.id ? { ...todo, ...updatedTodo } : todo
            )
        );

        return { previousTodos };
    },
    onError: (err, updatedTodo, context) => {
        console.error("updateTodoMutation", err)
        queryClient.setQueryData('todos', context.previousTodos);
        alert(err.message)
    },
    onSettled: () => {
        queryClient.invalidateQueries('todos');
    },
});

// Delete to-do mutation (optimistic update)
const deleteTodoMutation = useMutation(deleteTodo, {
    onMutate: async (id) => {
        await queryClient.cancelQueries('todos');
        const previousTodos = queryClient.getQueryData('todos');

        queryClient.setQueryData('todos', (old) =>
            old.filter((todo) => todo.id !== id)
        );

        return { previousTodos };
    },
    onError: (err, id, context) => {
        console.error("deleteTodoMutation", err)
        queryClient.setQueryData('todos', context.previousTodos);
        alert(err.message)
    },
    onSettled: () => {
        queryClient.invalidateQueries('todos');
    },
});

const handleAddTodo = async () => {
if (newTodoText.trim() === ”) return;
addTodoMutation.mutate({ text: newTodoText });
setNewTodoText(”);
};

const handleToggleComplete = async (todo) => {
    updateTodoMutation.mutate({ ...todo, completed: !todo.completed });
};

const handleDeleteTodo = async (id) => {
    deleteTodoMutation.mutate(id);
};

if (isLoading) return

Loading…

;
if (isError) return

Error: {error.message}

;

return (

To-Do List

  <label>
    <input
      type="checkbox"
      checked={errorMode}
      onChange={() => setErrorMode(!errorMode)}
    />
    Simulate Error
  </label>

  <div>
    <input
      type="text"
      value={newTodoText}
      onChange={(e) => setNewTodoText(e.target.value)}
      placeholder="Add a new to-do"
    />
    <button onClick={handleAddTodo} disabled={addTodoMutation.isLoading}>
      {addTodoMutation.isLoading ? 'Adding...' : 'Add To-Do'}
    </button>
  </div>

  <ul>
    {todos.map((todo) => (
      <li key={todo.id} style={{ display: 'flex', alignItems: 'center' }}>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={() => handleToggleComplete(todo)}
        />
        <span style={{ textDecoration: todo.completed ? 'line-through' : 'none', marginRight: '10px' }}>
          {todo.text}
        </span>
          <button onClick={() => handleDeleteTodo(todo.id)} disabled={deleteTodoMutation.isLoading}>
              {deleteTodoMutation.isLoading ? "Deleting..." : "Delete"}
          </button>
      </li>
    ))}
  </ul>
</div>

);
}

export default TodoList;
“`

3. App Component (App.js)

“`javascript
// App.js
import React from ‘react’;
import TodoList from ‘./TodoList’;

function App() {
return (

);
}

export default App;
“`

Explanation of the Code

  • api.js: This file simulates API calls with delays to mimic real-world network latency. The addTodoWithError function is used to test error handling.

  • TodoList.js:

    • useQuery('todos', getTodos): Fetches the initial list of to-dos. The 'todos' string is the query key, which is used by React Query for caching and invalidation.
    • useMutation(addTodo, { ... }): Defines the mutation for adding a new to-do. The addTodo function from api.js is the actual mutation function. The { ... } object contains the configuration options for the mutation, including onMutate, onError, and onSettled.
    • onMutate:
      • queryClient.cancelQueries('todos'): Cancels any ongoing fetches for the ‘todos’ query.
      • queryClient.getQueryData('todos'): Gets the current to-do list from the cache.
      • queryClient.setQueryData('todos', ...): Updates the cache with the new to-do item immediately. We’re adding a temporary id using Date.now(). The real ID will come from the server.
      • return { previousTodos }: Returns the previous to-dos as a context object.
    • onError:
      • queryClient.setQueryData('todos', context.previousTodos): Restores the cache to its previous state using the previousTodos from the context.
    • onSettled:
      • queryClient.invalidateQueries('todos'): Marks the ‘todos’ query as stale, causing React Query to refetch the data from the server in the background. This ensures that the UI eventually reflects the server’s state.
    • handleAddTodo: Calls addTodoMutation.mutate to trigger the mutation. We pass the new to-do text to the mutation function.
    • Error Simulation: The errorMode state variable and the conditional errorMode ? addTodoWithError : addTodo allow you to toggle between a successful and a failed mutation. This is useful for testing the error handling.
    • Update ToDo Optimistically the code for updateTodoMutation implements the same logic of addTodoMutation but applies to the case of an update. It is important to preserve immutability in the setQueryData calls.
    • Delete ToDo Optimistically The deleteTodoMutation follows the same structure, filtering the todo item from the cached array.
    • Loading States The code includes isLoading states from both the useQuery and useMutation hooks. These are used to provide visual feedback to the user during data fetching and mutation execution. Disabling the buttons during loading prevents multiple simultaneous mutations.
  • App.js: Simply renders the TodoList component.

Running the Application

  1. Start your React development server:

    “`bash
    npm start

    or

    yarn start
    “`

  2. Open your browser and navigate to the address where your app is running (usually http://localhost:3000).

Testing the Optimistic Update

  1. Add a new to-do: Type something into the input field and click “Add To-Do”. You should see the new to-do appear immediately in the list, even though the simulated network request takes 500ms.

  2. Simulate an error: Check the “Simulate Error” checkbox. Now, try adding a new to-do. The to-do will appear briefly, then disappear, and an error message will be displayed. This demonstrates the rollback mechanism.

  3. Observe the background refetch: After adding a to-do (without simulating an error), the list will be refetched in the background. This happens because of queryClient.invalidateQueries('todos') in the onSettled callback. You can observe this in the Network tab of your browser’s developer tools.

  4. Update and Delete a ToDo: Test the updateTodoMutation and deleteTodoMutation by checking/unchecking to-dos and clicking the “Delete” button, respectively. Observe how the UI updates optimistically.

Best Practices and Advanced Techniques

  • Error Handling: The example uses a simple alert for error handling. In a real application, you should use a more robust error display mechanism, such as a toast notification or an error boundary.

  • Unique IDs: In the example, we used Date.now() to generate temporary IDs for optimistically added to-dos. This works for simple cases, but it’s not guaranteed to be unique, especially if multiple users are adding to-dos concurrently. A better approach is to use a library like uuid to generate universally unique identifiers (UUIDs):

    bash
    npm install uuid

    “`javascript
    import { v4 as uuidv4 } from ‘uuid’;

    // Inside onMutate:
    queryClient.setQueryData(‘todos’, (old) => […old, { …newTodo, id: uuidv4(), completed: false }]);
    “`

  • Debouncing/Throttling: If users can trigger mutations rapidly (e.g., by typing quickly in an input field), you might want to debounce or throttle the mutation calls to avoid sending too many requests to the server. Libraries like lodash provide debounce and throttle functions.

  • Using updater functions: The setQueryData function can accept an “updater” function, which receives the old data as an argument and returns the new data. This is particularly useful when the new data depends on the old data, and it ensures that you’re always working with the latest cached data.

  • More Complex Data Structures: The to-do list example uses a simple array. If you’re working with more complex data structures (e.g., nested objects, normalized data), you’ll need to be careful to update the cache immutably. Libraries like immer can help with this.

  • Optimistic Updates with useInfiniteQuery: React Query also supports optimistic updates with useInfiniteQuery, which is used for fetching data in pages or chunks (e.g., for infinite scrolling). The principles are similar, but you’ll be working with an array of pages instead of a single data object.

  • Query Cancellation Token: The queryClient.cancelQueries() method returns a Promise that will be resolved when all the current queries are cancelled, or rejected if some query was unable to be cancelled.

Example with immer

“`javascript
import { useMutation, useQueryClient } from ‘react-query’;
import { produce } from ‘immer’; // Import immer

// … other imports

const updateTodoMutation = useMutation(updateTodo, {
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries(‘todos’);
const previousTodos = queryClient.getQueryData(‘todos’);

// Use immer's produce function for immutable updates
queryClient.setQueryData('todos', (oldTodos) =>
  produce(oldTodos, (draftTodos) => {
    const index = draftTodos.findIndex((todo) => todo.id === updatedTodo.id);
    if (index !== -1) {
      draftTodos[index] = { ...draftTodos[index], ...updatedTodo };
    }
  })
);

return { previousTodos };

},
// … onError and onSettled
});
“`

Conclusion

Optimistic updates are a powerful technique for improving the perceived performance and user experience of your React applications. React Query makes implementing optimistic updates straightforward and provides a structured approach to handling potential errors and ensuring data consistency. By following the steps and best practices outlined in this tutorial, you can create responsive and engaging applications that delight your users. Remember to consider the specific needs of your application and choose the appropriate techniques for managing unique IDs, handling errors, and updating complex data structures.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top