- Published on
Mastering Optimistic Updates in React with React Query: A Practical Guide
- Authors
- Name
- Frank Atukunda
- @fatukunda
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:
useMutation
Hook: This hook handles the mutation logic (in this case, adding a new to-do).- Mutation Function: The first argument to
useMutation
is an asynchronous function that performs the actual API request usingfetch
. 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 theuseMutation
hook. onSuccess
Callback: After the mutation succeeds, theonSuccess
callback is executed. Here, we invalidate thetodos
query usingqueryClient.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.handleSubmit
Function: This function is called when the form is submitted. It retrieves the to-do text from the input field and callsmutation.mutate
to trigger the mutation. It also handles errors that may occur during the mutation process, such as network errors.- Loading and Error States: The component displays loading and error messages based on the
mutation.isLoading
andmutation.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.
onMutate
Adding Optimistic Updates with 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.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.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.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.return { previousTodos }
: Returns a context object containing the previous value of the query data. This is essential for rolling back the update in theonError
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 thetodos
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.
onSettled
for Final Cleanup
Using 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:
- Update the cache for the specific page where the new to-do belongs.
- 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.
- Consider the possibility that the newly added item should appear on a different page.
You might use the
updater
function withsetQueryData
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:
- Dive Deeper into React Query: Refer to the TanStack Query documentation for a comprehensive understanding of its features and capabilities. Specifically, check the Mutations and Invalidation sections.
- Further Reading: Investigate advanced topics by checking out articles and documentation on topics like conflict resolution, pagination, and infinite scrolling with React Query. For example:
By mastering optimistic updates, you can create more responsive, engaging, and user-friendly React applications.