Create your own
Lesson illustration

Transaction Feedback: UI Best Practices

Hello! Welcome back to our course.

In our last lesson, we took a major step forward by learning how to construct a transaction from our UI and submit it to the Solana network using Anchor's program.rpc method. We established the fundamental importance of wrapping these calls in a try...catch block to handle the basic outcomes of success and failure.

Today, we're going to refine that process dramatically. As a seasoned front-end developer, you know that a good user experience is about clear communication. It's not enough to simply know if an asynchronous operation succeeded or failed; we must guide the user through the entire process. This lesson focuses on precisely that, addressing the learning outcome: Provide UI feedback for transaction submission, confirmation, and error states.

We will explore how to model the lifecycle of a transaction in our React state and use that state to render informative, non-blocking, and user-friendly feedback.

Modeling the Transaction Lifecycle in State

A Solana transaction doesn't happen instantaneously. From the user's perspective, it moves through several distinct stages:

  1. Idle: The initial state, before the user initiates the transaction.
  2. Submitting: The dApp is constructing the transaction, sending it to the wallet for signing, and submitting it to the Solana cluster.
  3. Success: The transaction has been successfully processed and confirmed by the network.
  4. Error: The transaction failed at some point (e.g., wallet rejection, network error, program logic error).

To manage these stages, we can use React's useState hook. A common pattern is to use a loading state, a result state, and an error state.

Let's look at a fantastic, practical example of this pattern.

Client Implementation for Gasless Transactions

The following resource provides a React component that implements gasless transactions. While the 'gasless' part is advanced, the component's UI handling is a perfect, self-contained example of what we want to achieve. We will focus on how it manages and displays state throughout the transaction lifecycle.

Please review the code for the Send.tsx component. Pay close attention to the state variables (isLoading, result, txSignature) and how they are updated within the sendUSDC function's try...catch...finally block. Also, note how these state variables are used in the JSX to conditionally render UI elements.

Dissecting the UI Feedback Pattern

As you saw in the Send.tsx example, the implementation is quite elegant. Let's break down the key pieces:

1. State Declaration:
Three state variables are used to track the entire process:

const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState("");
const [txSignature, setTxSignature] = useState<string | null>(null);
  • isLoading: A boolean to track when the transaction is in-flight. This is perfect for disabling buttons or showing spinners.
  • result: A string to hold user-facing messages, like "Preparing transaction..." or "Success!".
  • txSignature: A string to store the transaction signature upon success, allowing you to link to a block explorer.

2. State Updates within try...catch...finally:
This is where the state machine logic lives.

const sendUSDC = async () => {
    // Reset state for new submissions
    setResult("");
    setTxSignature(null);

    try {
        setIsLoading(true);
        setResult("Preparing transaction...");

        // ... API call, etc. ...

        setResult("Please sign the transaction...");
        
        // ... await signer.signAndSendTransaction(transaction) ...
        const { signature } = await signer.signAndSendTransaction(transaction);

        setTxSignature(signature);
        setResult(`USDC transfer successful!`);

    } catch (error) {
        setTxSignature(null); // Clear signature on error
        setResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
    } finally {
        setIsLoading(false); // Always stop loading, regardless of outcome
    }
};

Notice how setResult is used to provide a running commentary to the user. The finally block is crucial for ensuring the isLoading state is always reset.

3. Conditional Rendering in JSX:
The state variables are then used to control the UI.

// Disable the button while loading
<button disabled={isLoading || ...}>
  {isLoading ? "Processing..." : "Send USDC"}
</button>

// Display the result and transaction signature
{result && (
  <div className="result">
    <p>{result}</p>
    {txSignature && (
      <a href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`}>
        View on Solana Explorer
      </a>
    )}
  </div>
)}

This pattern provides a complete feedback loop, which is essential for a good dApp experience.

Non-Blocking Feedback: The Power of Toasts

While updating text on the page is effective, it can sometimes be disruptive. A very common and professional UX pattern for asynchronous actions is using "toast" notifications—small pop-up messages that appear for a few seconds.

The popular create-solana-dapp template, which we will use, leverages this pattern. Libraries like react-hot-toast make this incredibly easy to implement.

Let's see how we could refactor our try...catch block to use toasts:

import toast from 'react-hot-toast';

const submitTransaction = async () => {
  // `toast.loading` returns an ID we can use to update this specific toast later.
  const toastId = toast.loading("Submitting transaction...");

  try {
    const signature = await program.rpc.myInstruction({
      /* accounts */
    });
    
    // Update the original toast to a success message.
    toast.success(`Success! Transaction confirmed.`, { id: toastId });
    console.log(`Transaction Signature: ${signature}`);
    // You can add a link to the explorer in the toast as well.
    
    // After success, re-fetch on-chain data to update the UI.
    fetchAccountData();

  } catch (error) {
    // Update the original toast to an error message.
    toast.error(`Transaction failed: ${error.message}`, { id: toastId });
    console.error("Transaction error:", error);
  }
};

This approach provides immediate, non-blocking feedback and keeps your main component UI clean. The create-solana-dapp template includes this out of the box for actions like requesting an airdrop, as noted in the guide we reviewed previously. It couples the toast notification with invalidating the React Query cache to re-fetch data, a robust pattern you'll find very familiar.

A Deeper Look at Error Handling in React

The simple useState pattern for errors works well for many cases. However, in complex applications, you might want a more powerful, centralized way to handle errors, especially unexpected ones. Standard React error boundaries have a significant limitation: they do not catch errors from asynchronous code, which is what virtually all of our transaction submissions are.

Fortunately, the community has provided an excellent solution.

The Only Right Way To Handle Errors in React - No More Error Boundaries

This video compares different error handling strategies in React. We'll focus on the third and most robust solution, which is perfectly suited for handling errors from asynchronous dApp interactions.

Please watch the section 'Using the react-error-boundary Package' (timestamp 00:09:03 to 00:12:56). Pay attention to how this package overcomes the limitations of native error boundaries, especially its ability to handle errors from async functions using the useErrorBoundary hook.

The react-error-boundary package allows you to wrap parts of your application and provide a fallbackComponent that will be rendered if any child component throws an error. Crucially, its useErrorBoundary hook gives you a showBoundary function that you can call inside your catch block:

const { showBoundary } = useErrorBoundary();

// ... inside your catch block
catch (error) {
    // Instead of setting local state, you can propagate the error
    // to the nearest error boundary.
    showBoundary(error);
}

This lets you separate your transaction logic from your error display logic, leading to cleaner components. For a large dApp, this pattern can be invaluable for creating consistent and reusable error UIs.

Test your understanding!

You've just submitted a transaction to update a user's profile. You want to give the user the best possible feedback. Which of the following actions should you take inside your try block after the await program.rpc.updateProfile(...) call successfully completes?

  1. Immediately call window.location.reload() to refresh the entire page.
  2. Set a state variable like setIsSuccess(true) and display a generic "Success!" message.
  3. Display a success toast, re-fetch only the updated profile data from the chain, and update the component's state with the new data.
  4. Do nothing, assuming the user will see the transaction confirmation in their wallet.
Show answer

The best answer is 3.

  • 1 is a poor user experience. A full-page reload is slow and unnecessary.
  • 2 is good, but not the best. A generic message is less informative than one that confirms the specific action, and it doesn't reflect the new on-chain state.
  • 3 is the ideal pattern. It provides immediate, non-blocking feedback (toast), fetches the fresh data to ensure the UI is consistent with the blockchain (the "source of truth"), and avoids a jarring full-page reload.
  • 4 is incorrect. You should never assume the user is monitoring their wallet. Your dApp is responsible for communicating its own state changes.

Conclusion

In this lesson, we elevated our dApp's user experience from basic to professional. By treating a transaction as a process with a distinct lifecycle, we can provide clear, timely, and helpful feedback to the user at every step.

Key Takeaways:

  • Model the Lifecycle: Represent the idle, submitting, success, and error states of a transaction using React state.
  • Use State for Conditional UI: Drive your UI from this state to disable buttons, show loading indicators, and display success or error messages.
  • Embrace Toasts: Use toast notifications for non-blocking feedback that informs the user without disrupting their workflow.
  • try...catch...finally is Your Friend: Structure your transaction submission logic carefully to ensure state is managed correctly, especially resetting loading states in the finally block.
  • Plan for Robust Error Handling: For larger applications, consider using react-error-boundary to gracefully handle async errors and create consistent fallback UIs.

Preview of the Next Lesson:

We've talked about the "submitting" and "success" states, but what does "success" really mean on a distributed network? A transaction goes through stages of being processed, confirmed, and finally finalized. Understanding these "commitment levels" is crucial for building applications that are both fast and reliable. In our next lesson, we will explore what each level means and how to use them to fine-tune your dApp's responsiveness and data consistency.

Can't find a good explanation? Sign up and we'll make it for you

Sign up