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
anduseEffect
), 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:
-
useMutation
: This hook is used for performing mutations (create, update, delete operations). It provides functions and state related to the mutation process. -
onMutate
: This is the crucial callback function for optimistic updates. It runs before the actual mutation function is executed. InsideonMutate
, 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
andonSettled
callbacks.
-
onError
: This callback function runs if the mutation function throws an error (i.e., the server operation fails). InsideonError
, 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.
- Roll back the optimistic update: You use the snapshot from the context object (returned by
-
onSettled
: This callback function runs after the mutation is complete, regardless of whether it succeeded or failed. InsideonSettled
, 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.
-
queryClient.setQueryData
: This function is used to directly update the data in the React Query cache. You use this inonMutate
to apply the optimistic update and inonError
to roll back the changes. -
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. -
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
;
if (isError) return
;
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. TheaddTodoWithError
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. TheaddTodo
function fromapi.js
is the actual mutation function. The{ ... }
object contains the configuration options for the mutation, includingonMutate
,onError
, andonSettled
.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 temporaryid
usingDate.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 thepreviousTodos
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
: CallsaddTodoMutation.mutate
to trigger the mutation. We pass the new to-do text to the mutation function.- Error Simulation: The
errorMode
state variable and the conditionalerrorMode ? 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 ofaddTodoMutation
but applies to the case of an update. It is important to preserve immutability in thesetQueryData
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 theuseQuery
anduseMutation
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 theTodoList
component.
Running the Application
-
Start your React development server:
“`bash
npm startor
yarn start
“` -
Open your browser and navigate to the address where your app is running (usually
http://localhost:3000
).
Testing the Optimistic Update
-
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.
-
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.
-
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 theonSettled
callback. You can observe this in the Network tab of your browser’s developer tools. -
Update and Delete a ToDo: Test the
updateTodoMutation
anddeleteTodoMutation
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 likeuuid
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
providedebounce
andthrottle
functions. -
Using
updater
functions: ThesetQueryData
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 withuseInfiniteQuery
, 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.