We have all noticed in some applications that when clicking the like button or increasing the count, the changes are updated instantly without causing any loading or waiting for the request to complete. These actions are called optimistic updates. Instead of waiting for the api to respond, the application assumes that the request will succeed and updates the UI immediately. If something goes wrong or the API fails, it can simply roll back the change.
Optimistic updates make our applications feel much faster and more responsive, and these are a common feature in modern applications. In this blog, we can learn how to implement optimistic updates in React using TanStack Query.
What Are Optimistic Updates?
Normally, when a user performs an action in an app, its flow will be like this: User clicks a button - application sends a request to the server - application waits for response - once the response arrives, the UI updates.
This approach works, but it can make the application feel slower. In the case of optimistic updates, the flow changes: the user clicks a button - the UI updates immediately - a request is sent in the background - if the request succeeds, nothing changes - if the request fails, UI reverts to the previous state. In this case, the ui updates immediately and the user gets instant feedback for the action.
Setting Up the Project
First, create a React app using Vite and install the TanStack Query package.
npm create vite@latest
npm install
npm install @tanstack/react-query
Now, we need to setup query client. Open your main.jsx or main.tsx file. The QueryClientProvider gives the app access to the TanStack Query features.
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
Fetching the Todo List
First, let's create a function to fetch the list of todos. Here I am using the demo REST API.
const fetchTodos = async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos?_limit=10"
);
if (!response.ok) {
throw new Error("Failed to fetch todos");
}
return response.json();
};const { data: todos } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});Creating a Mutation
Now, we want to create a mutation function to mark a todo as complete.
const updateTodo = async (todo) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todo.id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(todo),
}
);
if (!response.ok) {
throw new Error("Failed to update todo");
}
return response.json();
};const mutation = useMutation({
mutationFn: updateTodo,
});Step 1: Update the Cache Immediately
The useMutation hook has an onMutate callback; this callback runs before the mutation function executes. This is where we will add the optimistic update logic.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Stop any outgoing refetches
await queryClient.cancelQueries({
queryKey: ["todos"],
});
// Save current cache
const previousTodos = queryClient.getQueryData(["todos"]);
// Update cache immediately
queryClient.setQueryData(["todos"], (old = []) =>
old.map((todo) =>
todo.id === updatedTodo.id
? { ...todo, completed: true }
: todo
)
);
// Returned value becomes "context"
return { previousTodos };
},
});Step 2: Roll Back if Something Fails
It is recommended to set up the onError callback to handle the rollback of the update if the API request failed for any reason. If the mutation fails, we will simply restore the data; the user sees the original state again.
onError: (error, variables, context) => {
queryClient.setQueryData(
["todos"],
context?.previousTodos
);
},Step 3: Refresh the Data
Even if the api request succeeds, it is a good idea to sync data with the server. We can use the onSettled callback for this case.
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["todos"],
});
},Full code
Here, you can see the complete optimistic mutation code
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
// Fetch all todos
const fetchTodos = async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos?_limit=10"
);
if (!response.ok) {
throw new Error("Failed to fetch todos");
}
return response.json();
};
// Update a todo
const updateTodo = async (todo) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todo.id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(todo),
}
);
if (!response.ok) {
throw new Error("Failed to update todo");
}
return response.json();
};
export default function TodoList() {
const queryClient = useQueryClient();
// Fetch todos
const {
data: todos = [],
isPending,
error,
} = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
// Optimistic mutation
const mutation = useMutation({
mutationFn: updateTodo,
// Runs before the request
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries({
queryKey: ["todos"],
});
const previousTodos =
queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old = []) =>
old.map((todo) =>
todo.id === updatedTodo.id
? {
...todo,
completed: updatedTodo.completed,
}
: todo
)
);
// Returned value becomes "context"
return { previousTodos };
},
// Rollback if request fails
onError: (error, variables, context) => {
queryClient.setQueryData(
["todos"],
context?.previousTodos
);
},
// Sync with server
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["todos"],
});
},
});
if (isPending) {
return <p>Loading...</p>;
}
if (error) {
return <p>Something went wrong.</p>;
}
return (
<div>
<h2>Todo List</h2>
{todos.map((todo) => (
<div
key={todo.id}
style={{
display: "flex",
gap: "10px",
marginBottom: "10px",
}}
>
<span
style={{
textDecoration: todo.completed
? "line-through"
: "none",
}}
>
{todo.title}
</span>
<button
onClick={() =>
mutation.mutate({
...todo,
completed: !todo.completed,
})
}
>
Toggle
</button>
</div>
))}
</div>
);
}Optimistic updates are one of the easiest ways to make a React application faster. Instead of the user waiting for the server response, the user sees the updates or changes instantly; this creates a smoother and more responsive experience. TanStack Query makes this process more straightforward.
To read more about How to Fetch Paginated Data with TanStack Query in React, refer to our blog How to Fetch Paginated Data with TanStack Query in React.