Skip to main content
react

The Interface Should Tell You What Just Happened

Feedback patterns in UI — how interfaces communicate the result of user actions, when toasts fail, and what to use instead.


5 min read

When a user clicks "Save," they need to know three things immediately: did it work, did it fail, or is it still working? The answer determines everything about how they proceed. Get it wrong and they click again, navigate away assuming nothing happened, or miss a critical error buried in the wrong place. The tools are straightforward — react-hot-toast, sonner, inline state transitions, optimistic updates via TanStack Query — but the decisions about when to use each one aren't obvious. Most codebases default to toasts for everything, and that's where feedback goes wrong.


The Toast Spectrum

Toasts work well for a narrow set of situations: confirmations for actions the user already knows succeeded, low-stakes informational nudges, and transient system events that don't require action. The canonical good use is a success state after an async operation completes without error.

import { toast } from "sonner";

async function handleSave(data: FormData) {
  try {
    await saveProfile(data);
    toast.success("Profile updated");
  } catch (err) {
    // not here — see below
  }
}

That toast is useful because the user triggered the action, waited, and now needs confirmation the wait was worth it. It's temporary because the confirmation is temporary — they don't need to keep seeing it.

The failure mode starts when you use the same pattern for errors.

When Toasts Are the Wrong Tool

A toast for a form validation error is wrong for a structural reason: the error belongs in the form. If a user's email is invalid, the feedback belongs next to the email field with a message explaining why. A toast that says "Please check your form" dismisses in four seconds and gives the user no spatial anchor — they have to hunt for the problem.

// wrong
toast.error("Email is invalid");

// right — colocate the error with the field
<Input
  error={errors.email?.message}
  aria-describedby="email-error"
/>
{errors.email && (
  <p id="email-error" role="alert" className="text-sm text-red-600">
    {errors.email.message}
  </p>
)}

The rule: if an error has a location in the UI, put it there.

Destructive actions are a separate problem. If a user deletes something and you show a toast that says "Deleted," you've thrown away their ability to undo without surfacing that affordance prominently enough. The toast needs to carry the undo action inline — not as a separate flow, not linked to a settings page. sonner supports action buttons directly:

toast("Invoice deleted", {
  action: {
    label: "Undo",
    onClick: () => restoreInvoice(id),
  },
  duration: 8000, // give them time
});

But if the action is permanent and severe — deleting an account, publishing to production — don't use a toast at all. Use a confirmation dialog that forces a deliberate decision, not a reversible notification.

Inline Success States

The most underused feedback pattern is the inline success state: the button or form element itself communicates success, then returns to its neutral state.

function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  const [state, setState] = useState<"idle" | "saving" | "saved">("idle");

  async function handleClick() {
    setState("saving");
    await onSave();
    setState("saved");
    setTimeout(() => setState("idle"), 2000);
  }

  return (
    <button onClick={handleClick} disabled={state === "saving"}>
      {state === "idle" && "Save"}
      {state === "saving" && "Saving..."}
      {state === "saved" && "Saved ✓"}
    </button>
  );
}

This is more appropriate than a toast for save actions that are frequent and low-stakes. The feedback is in-place, it disappears naturally, and there's no popup competing for attention. GitHub uses this pattern on star buttons. Most form-heavy applications should use it more.

Animated State Transitions as Feedback

Motion communicates state change. A button that snaps from "Save" to "Saved" doesn't feel like confirmation — it feels like a text swap. An animation with a checkmark drawing in tells the user something actually completed.

Framer Motion makes this straightforward:

import { AnimatePresence, motion } from "framer-motion";

<AnimatePresence mode="wait">
  {state === "saved" ? (
    <motion.span
      key="saved"
      initial={{ opacity: 0, scale: 0.8 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0 }}
    >
      Saved ✓
    </motion.span>
  ) : (
    <motion.span key="save">Save</motion.span>
  )}
</AnimatePresence>

Keep durations short — 150–250ms for confirmations. Longer than that and the animation becomes the subject rather than the signal.

The Hierarchy

When something happens, pick feedback placement by asking where the user's attention is:

  1. On the element that triggered the action → inline state transition
  2. In the form the action relates to → inline error message with role="alert"
  3. System-level, no specific location → toast notification
  4. Permanent, severe, or reversible → dialog or toast with undo, never bare toast

Toasts-for-everything is a symptom of not answering this question. The interface knows what just happened. The feedback mechanism should match the weight of the information.