Hello! Welcome to the final lesson of our module on advanced dApp state management.
In our last lesson, we built a robust error-handling system for our transaction state machine. You learned how to catch and interpret various errors—from user rejections in the wallet to dropped transactions on the network—and provide clear, user-friendly feedback. This was a crucial step in making our dApp production-ready.
However, even when everything goes right, Solana transactions take time to finalize. The user clicks a button, signs the transaction, and then... waits, watching a loading spinner. This waiting period, even if it's just a few seconds, can make an application feel sluggish.
Today, we'll tackle this challenge head-on. This lesson's goal is to implement optimistic UI updates for a smoother user experience while waiting for transaction finalization. As an experienced frontend developer, you're likely familiar with this concept from the web2 world. We'll explore how to apply it effectively in the unique context of a blockchain application, leveraging your React expertise to create dApps that feel instantaneous.
1. The "Optimistic" Philosophy
At its core, an optimistic UI update is a simple but powerful trick: Assume the user's action will succeed and update the UI immediately, before getting confirmation from the backend.
Instead of this flow:
- User clicks "Submit".
- Show a loading spinner.
- Send the transaction to the Solana network.
- Wait for finalization.
- Hide the spinner and update the UI with the new data.
We adopy this "optimistic" flow:
- User clicks "Submit".
- Immediately update the UI with the expected new state.
- In the background, send the transaction to the Solana network.
- If the transaction succeeds, the UI is already correct. We just replace the temporary data with the final, confirmed data from the chain.
- If the transaction fails, roll back the UI to its previous state and show an error message.
This makes the application feel incredibly responsive, as the user gets instant feedback for their actions.
Fast ecommerce with optimistic UI and Next.js
For a quick visual demonstration of this concept, watch the first 43 seconds of this video from the leerob channel. It contrasts a traditional loading state with a snappy optimistic UI in an e-commerce context.
Watch the introduction (0:00 - 0:43) to see the user experience difference between a standard and an optimistic UI.
While the concept is common in web2, it's particularly vital in web3, where "server" confirmation (i.e., block finalization) has inherent latency.
How do you sync on-chain, off-chain, and UI states in Web3?
The article 'How do you sync on-chain, off-chain, and UI states in Web3?' positions optimistic UI as a key best practice for client-side state management in dApps.
Read section 3 on 'Client-side state management' to see how optimistic updates fit into the overall architecture. Also, glance at section 6, 'Balancing consistency vs. UX,' and the final 'Key Takeaways,' which emphasize the pattern of applying an optimistic update and then rolling back on failure.
The key takeaway is that optimistic updates are a trade-off: we sacrifice immediate consistency for a better user experience, with the guarantee of eventual consistency by always reconciling with the on-chain source of truth.
2. Implementing Optimistic Updates with TanStack Query
In the React ecosystem, you can implement this pattern manually or with the new useOptimistic hook. However, since we've already discussed using a client-side caching library like React Query (now TanStack Query), the most robust and common approach is to use its built-in support for mutations.
TanStack Query's useMutation hook provides a powerful lifecycle API that makes implementing optimistic updates straightforward and safe.
TanStack React Query: Crash Course
This article from dev.to provides a great crash course on TanStack Query, with a specific section on optimistic updates. It will be our main guide for the implementation.
Please read the section 'Implementing Optimistic Updates' carefully. Focus on understanding the roles of onMutate, onError, and onSettled and study the code example provided.
Let's break down the process described in the article. When you define a useMutation, you can provide three key callback functions:
-
onMutate(variables): This function is called before the mainmutationFnis executed. It's where you perform the optimistic update.- Cancel Queries: Call
queryClient.cancelQueries()to prevent any in-flight data fetching from overwriting your optimistic update. - Snapshot State: Get the current data from the cache using
queryClient.getQueryData(). This is your rollback point. - Optimistically Update: Manually set the new, temporary state in the cache with
queryClient.setQueryData(). - Return Context: Return the snapshot of the previous state. This will be passed to
onErrorandonSettled.
- Cancel Queries: Call
-
onError(error, variables, context): This is called if themutationFnthrows an error. It's your rollback mechanism.- Using the
context(which contains your snapshot fromonMutate), revert the cache to its previous state withqueryClient.setQueryData().
- Using the
-
onSettled(data, error, variables, context): This function is called after the mutation is complete, whether it succeeded or failed.- Regardless of the outcome, you should always call
queryClient.invalidateQueries()here. This tells TanStack Query to refetch the data from the source of truth (the blockchain), ensuring the UI is eventually consistent with the on-chain state.
- Regardless of the outcome, you should always call
3. A Practical Example: An On-Chain Guestbook
Let's apply this to a hypothetical "Solana Guestbook" dApp. Imagine we have a list of messages fetched from the chain and a form to add a new one.
-
Our messages are fetched with
useQuery:const { data: messages, isLoading } = useQuery({ queryKey: ['guestbookMessages'], queryFn: fetchMessagesFromChain }); -
Our function to submit the transaction is
postMessageToChain. This is the asynchronous function that builds, signs, sends, and confirms the transaction, just like we've been working on in previous lessons.
Now, let's wire this up with useMutation for an optimistic update.
import { useMutation, useQueryClient } from '@tanstack/react-query';
// Assume you have a component that uses this hook
function useCreateMessageMutation() {
const queryClient = useQueryClient();
return useMutation({
// The actual async function that sends the transaction to Solana
mutationFn: postMessageToChain,
// 1. Called immediately when mutation.mutate() is called
onMutate: async (newMessageText: string) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['guestbookMessages'] });
// Snapshot the previous value
const previousMessages = queryClient.getQueryData<Message[]>(['guestbookMessages']);
// Optimistically update to the new value
queryClient.setQueryData<Message[]>(['guestbookMessages'], (oldMessages = []) => [
...oldMessages,
{
// Create a temporary object for the new message
text: newMessageText,
author: connectedWallet.publicKey, // From wallet adapter
timestamp: Date.now(),
isPending: true, // Add a flag to style it differently in the UI
},
]);
// Return a context object with the snapshotted value
return { previousMessages };
},
// 2. If the mutation fails, use the context returned from onMutate to roll back
onError: (err, newMessage, context) => {
console.error("Transaction failed, rolling back UI", err);
if (context?.previousMessages) {
queryClient.setQueryData(['guestbookMessages'], context.previousMessages);
}
},
// 3. Always refetch after the mutation is settled (success or error)
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['guestbookMessages'] });
},
});
}
In your component, you can now display the list of messages. The isPending flag allows you to give the user a visual cue that this specific item is not yet finalized, for example by reducing its opacity.
// Inside your Guestbook component
const { data: messages } = useQuery(/*...*/);
const createMessage = useCreateMessageMutation();
const handleSubmit = (text: string) => {
createMessage.mutate(text);
};
return (
<div>
<ul>
{messages?.map((msg, index) => (
<li key={index} style={{ opacity: msg.isPending ? 0.5 : 1 }}>
<p>{msg.text}</p>
<small>By: {msg.author.toBase58()}</small>
</li>
))}
</ul>
{/* Form to call handleSubmit */}
</div>
);
Test your understanding!
Imagine the user submits a new message. The postMessageToChain function fails because the user rejects the transaction in their wallet. Describe the sequence of events that occurs within our useCreateMessageMutation hook to handle this scenario.
Show answer
onMutateruns: The UI is immediately updated to show the new message with 50% opacity. The original list of messages is saved in thecontextobject.mutationFn(postMessageToChain) runs: This function attempts to sign and send the transaction. The user clicks "Reject" in their wallet, causing thesignTransactionpromise to reject. This rejection is caught, andpostMessageToChainthrows an error.onErrorruns: Because themutationFnfailed,onErroris triggered. It receives thecontextobject containing the original list of messages. It then callsqueryClient.setQueryDatato restore the cache to its pre-mutation state, causing the temporary message to disappear from the UI.onSettledruns: This runs last. It callsqueryClient.invalidateQueries, which triggers a refetch of the guestbook messages from the chain. This ensures the UI is perfectly in sync with the on-chain reality, even after the failed attempt.
This pattern elegantly combines the immediate feedback of an optimistic update with the robust error handling and finality checks we developed in the previous lessons.
Conclusion
Congratulations on completing this module on advanced dApp state management! By combining state machines, comprehensive error handling, and optimistic UI updates, you now possess the frontend architecture skills to build sophisticated, professional-grade Solana applications that feel fast, reliable, and user-friendly.
Your key takeaways from this lesson are:
- Optimistic UI is a UX multiplier: It dramatically improves perceived performance by providing instant feedback, which is especially important in the asynchronous world of blockchains.
- The core pattern is "Update, then Reconcile": Immediately update the local state, then let the asynchronous process (the transaction) catch up.
- Rollback is essential: An optimistic update without a reliable rollback mechanism for failures is a bug. Your UI must always return to a correct state if the action fails.
- Leverage your tools: Libraries like TanStack Query provide a structured and safe way to implement this pattern using the
onMutate,onError, andonSettledlifecycle hooks.
With this foundation, you are well-equipped to build complex user interfaces that interact seamlessly with the Solana blockchain. In the upcoming modules, we will shift our focus back to the on-chain world, exploring how to interact with the Solana Program Library (SPL) to create and manage tokens, which will give you new opportunities to apply these frontend patterns.
Can't find a good explanation? Sign up and we'll make it for you
Sign up