Optimistic UI: Enhancing User Experience in React with React Query
Lucas Wolff
Posted on June 13, 2023
In modern web development, delivering a smooth and responsive user experience is crucial. One aspect that may pass unnoticed by the final user is how the application handles data being mutated i.e. created, updated and removed.
This is probably due to the duality of the nature of this feature: if it works well, no one notices; if it doesn't, then the application is considered a bad one.
Users want to feel that they have control over what they're doing. Pretty much every loading state in your application has the potential of distracting the user, who needs to wait until the action finishes. But this is how apps are supposed to work, right?
If your application is solid and works well, you probably have noticed that most of the requests to mutate data in the server are successful. Even though most of the apps use loading states while mutating, then show the updated data when a response is received.
Today I'll explain to you which options do you have available to improve this user flow and why use them. Then I'll show you code examples of each method with step-by-step implementations. Keep reading!
The Traditional Method
The most used and traditional method of mutating data is the Pessimistic UI.
Pessimistic UI is an approach in UI/UX design where changes made by the user are not immediately reflected in the screen until the server response is received and confirmed.
When users perform an action like submitting a form or hitting a button the interface will remain unchanged until a response is received from the server. During this period the user might see a loading indicator and blocked buttons or inputs. When the response is received, then the UI will be updated with new data or a feedback will be given.
This pessimistic approach is employed and useful in situations where data consistency and accuracy are important. By deferring UI updates until the response is validated, the chances of showing incorrect and inconsistent data are reduced.
Some examples of Pessimistic UI uses are bank transactions and app sign in and sign ups, where security and consistency are preferred over a responsive interface.
But sometimes we need to give the user a much more sense of control and also provide a smoother app experience.
Imagine if you see a loading spinner every time you like an Instagram post. That's definitely not ideal, and you would not be fully engaged in the whole app experience.
To solve this issue, we have the Optimistic UI.
Understanding Optimistic UI
Optimistic UI is an approach that aims to provide immediate feedback to the user, and then fire the mutation request in the background.
When performing actions such as clicking on buttons or submitting forms the interface will be updated before the actual request is dispatched, so the user can continue the journey through the application. Meanwhile, the server is processing the request and eventually giving a response back to the frontend. If it's a failure response then the UI updates are rolled back, and if it's a successful response then nothing needs to be done—the UI is already updated.
The Optimistic UI approach is used in scenarios where feedback and responsiveness are crucial. It removes any perceived delay and creates a smoother user experience by assuming a successful response upfront.
As mentioned above, one example of Optimistic UI is the Instagram like button. When the button is clicked (like or unlike), the UI is updated upfront and then the request is dispatched. This is also great because some users may be using a slow network, so Optimistic UI is a problem solver in that case.
Now that you understand the differences and use cases of each method, let's dive into some code examples.
The scenario for our examples
Before jumping into the code, let me show you what we're going to be using in all of the examples.
Let's consider a simple React application which has a "Send message" button that when clicked dispatches a request to the server with, and then a new message is added to a list. Also, when the user enters the page the existing messages should be fetched from the API.
To simulate the API request, a promise will be resolved and a delay will be added to it, so you can understand better the overall behavior. Here are the two mocked API requests that we're going to use
// Default existing messages
const messages = [
{
id: 1,
message: "Existing message",
},
{
id: 2,
message: "Existing message",
},
];
// Return all the messages
export const getMessages = () =>
new Promise((resolve) => {
setTimeout(() => {
resolve(messages);
}, 1500);
});
// Add a new message to the messages array
export const sendMessage = (message) =>
new Promise((resolve) => {
setTimeout(() => {
messages.push(message);
resolve(messages);
}, 1500);
});
For the styles, I'll be using TailwindCSS.
All of the examples are on my GitHub, so feel free to clone the repository and play with it. All the details of how to run it are described in the README file.
With that said, let's jump to the code!
Pessimistic Updates without React Query
In order to make this work, we need to manage the loading behavior and add the new message to the state. This is basically what most of the apps do behind the scenes.
const PessimisticUpdate = () => {
const [isFetching, setIsFetching] = useState(false); // Used for initial fetching
const [isLoading, setIsLoading] = useState(false); // Used when sending a new message
const [messages, setMessages] = useState([]); // Local state to store messages
// Load existing messages from API when component mounts,
// and shows a loading state while fetching
useEffect(() => {
setIsFetching(true);
getMessages().then((messages) => {
setMessages(messages);
setIsFetching(false);
});
}, []);
const handleClick = () => {
setIsLoading(true); // Enable loading
const newMessage = {
id: messages.length + 1,
message: "New message",
};
// Dispatch API request to add the new message
sendMessage(newMessage).then((updatedMessages) => {
setMessages(updatedMessages); // Update messages in the local state
setIsLoading(false); // Disable loading
});
};
return (
<>
{!isFetching && <MessagesList messages={messages} />}
<Button
text="Send message"
onClick={handleClick}
isLoading={isLoading || isFetching}
/>
</>
);
};
Note that the feedback—isLoading
—is defined only after the promise resolves.
Optimistic Updates without React Query
The biggest difference here is that we'll not need isLoading
anymore, since we are considering upfront that everything will work fine when sending a message, so the UI feedback will be immediate, and no loading state will be needed for this.
const OptimisticUpdate = () => {
const [isFetching, setIsFetching] = useState(false); // Used for initial fetching
const [messages, setMessages] = useState([]); // Local state to keep messages
// Load existing messages from API when component mounts,
// and shows a loading state while fetching
useEffect(() => {
setIsFetching(true);
getMessages().then((messages) => {
setMessages(messages);
setIsFetching(false);
});
}, []);
const handleClick = () => {
const newMessage = {
id: messages.length + 1,
message: "New message",
};
// Update message in the local state upfront
setMessages((previousMessages) => [...previousMessages, newMessage]);
// Then dispatch API request to add new message
sendMessage(newMessage).then(() => {
console.log(`Message ${newMessage.id} added`);
});
};
return (
<>
<h1 className="font-bold text-xl mb-2 text-center">Optimistic UI</h1>
{!isFetching && <MessagesList messages={messages} />}
<Button
text="Send message"
onClick={handleClick}
isLoading={isFetching}
/>
</>
);
};
It was a pretty small change for a big improvement UX-wise. There are some trade-offs though.
We're not considering if the request fails. Removing the invalid data from the state is not straightforward in some cases and needs to be done manually. You'll probably need to add another state only to keep the temporary messages, along with the state with the actual messages.
Considering that a real-world application has much more things going on, you'll need to make sure to rollback all the UI changes too, and dispatch the correct actions if the request fails. This can be a little bit tricky and can have some complexity involved.
Pessimistic Updates with React Query
The biggest difference here is that React Query manages all the states that are related to messages and requests. Instead of calling the service directly in the component, it's passed to the React Hook and it's called immediately.
const PessimisticUpdateReactQuery = () => {
const cacheKey = "messages"; // Cache key of the messages
const queryClient = useQueryClient();
// Load existing messages from API
const { data: messages, isLoading: isFetching } = useQuery(
cacheKey,
getMessages
);
// Dispatch API request to add new message
const { mutateAsync, isLoading } = useMutation(sendMessage, {
onSuccess: () => {
// Invalidate messages cache, since a new message was added.
// The `messages` var will be automatically updated with fresh data.
queryClient.invalidateQueries(cacheKey);
},
});
const handleClick = () => {
const newMessage = {
id: messages.length + 1,
message: "New message",
};
// Call React Query hook that calls sendMessage
mutateAsync(newMessage);
};
return (
<>
{!isFetching && <MessagesList messages={messages} />}
<Button
text="Send message"
onClick={handleClick}
isLoading={isLoading || isFetching}
/>
</>
);
};
The code is much easier to read, since a lot of things are abstracted by the library.
You'll also have caching out of the box. If another call to the same service is made, the React Query hook will return the cached value instead of making a new API call, and you can configure the cache time as you want. This is a big performance improvement.
Optimistic Updates with React Query
As discussed in the example above, React Query will deal with all the server state, and you'll not need to manage any useState
from React.
Here we have the optimal version to deal with optimistic updates. All the behavior is under React Query responsibility, so we just need to call its APIs.
The code below may look confusing at first, but let's dive into it and understand all the changes that were made:
const OptimisticUpdateReactQuery = () => {
const cacheKey = "messages"; // Cache key of the messages
const queryClient = useQueryClient();
// Load existing messages from API
const { data: messages, isLoading: isFetching } = useQuery(
cacheKey,
getMessages
);
// Dispatch API request to add new message
// and do the optimistic update.
const { mutateAsync } = useMutation(sendMessage, {
onMutate: async (newMessage) => {
// Cancel any outgoing refetches (so they don't overwrite the optimistic update)
await queryClient.cancelQueries(cacheKey);
// Snapshot the previous value
const previousMessages = queryClient.getQueryData(cacheKey);
// Optimistically update to the new value
queryClient.setQueryData(cacheKey, (old) => [...old, newMessage]);
// Return a context object with the snapshotted value
return { previousMessages };
},
onError: (_, __, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
queryClient.setQueryData(cacheKey, context.previousMessages);
},
onSettled: (messages) => {
// Always refetch after error or success:
queryClient.invalidateQueries(cacheKey);
console.log(`Message ${messages[messages.length - 1].id} added`);
},
});
const handleClick = () => {
const newMessage = {
id: messages.length + 1,
message: "New message",
};
mutateAsync(newMessage);
};
return (
<section>
{!isFetching && <MessagesList messages={messages} />}
<Button text="Send message" onClick={handleClick} />
</section>
);
}
As mentioned before, this comes with a lot of advantages like caching, automatic invalidation, and refetching.
One great idea in this case is to abstract the optimistic behavior (under onMutate
) to a custom hook, then you can just call this hook that has the optimistic feature. The hook can be something like this:
const useOptimisticUpdate = () => {
const queryClient = useQueryClient();
const getPreviousData = async ({ cacheKey, newValue }) => {
await queryClient.cancelQueries(cacheKey);
// Snapshot the previous value
const previousData = queryClient.getQueryData(cacheKey);
// Optimistically update to the new value
queryClient.setQueryData(cacheKey, (old) => [...old, newValue]);
// Return a context object with the snapshotted value
return { previousData };
};
return { getPreviousData };
};
And then replace the calls from onMutate
with the hook:
const OptimisticUpdateReactQuery = () => {
// ...
const { getPreviousData } = useOptimisticUpdate(); // New hook call
const { mutateAsync } = useMutation(sendMessage, {
onMutate: async (newMessage) => ({
previousMessages: await getPreviousData({ // Get previous data from the hook
cacheKey,
newValue: newMessage,
})
}),
// ...
Now you have a reusable optimistic update feature that can be shared across every component that you need.
Wrapping up
The experiences that many types of applications deliver may be always focused on the final user. This is where Optimistic UI shines.
Optimistic updates significantly improve the user experience by providing immediate feedback and responsiveness during data updates. While traditional methods require manual state management and error handling, React Query simplifies the process by offering built-in mutation functions, automatic cache invalidation, and error handling. By leveraging React Query, you can enhance the applications with a smoother user experience, save time and effort in managing data updates manually.
One thing to consider is that not every case will fit this concept, sometimes it will be better to use the Pessimistic UI for many reasons, like security and data consistency.
Be very thoughtful to where apply one concept or another, and think about discussing the decision with designers and POs. They can contribute with other point of views because this decision is heavily based on the user experience.
If you have any feedback or suggestions, send me an email
Great coding!
Posted on June 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.