30 React UX Rules
Rules for building React interfaces that feel fast, forgiving, and intuitive. Covers loading states, error recovery, feedback, accessibility, and interaction design.
Perceived Performance (Rules 1-10)
1. Show something immediately. Never show a blank screen. Use skeleton loaders, placeholder content, or cached stale data while fresh data loads.
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails id={id} />
</Suspense>2. Use optimistic updates for user-initiated actions. When a user clicks "like," "save," or "delete," update the UI instantly. Roll back if the server rejects. Waiting for the server makes your app feel slow.
const [optimisticLikes, addLike] = useOptimistic(
likes,
(state, newLike: Like) => [...state, newLike]
);3. Show pending states on buttons and forms. Disable the submit button and show a spinner during submission. Users should never wonder if their click registered.
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending} type="submit">
{pending ? "Saving..." : "Save"}
</button>
);
}4. Use transitions for non-urgent updates. Wrap expensive state updates in useTransition to keep the UI responsive. The previous content stays interactive while the new content renders.
const [isPending, startTransition] = useTransition();
function handleSearch(query: string) {
startTransition(() => {
setSearchResults(filterLargeList(query));
});
}5. Prefetch routes the user is likely to visit. Next.js Link prefetches by default on hover. For programmatic navigation, use router.prefetch("/target").
6. Stream content progressively. Do not wait for all data before showing anything. Use multiple Suspense boundaries so each section appears as soon as its data is ready.
7. Debounce search inputs. Typing triggers on every keystroke. Debounce with 300ms delay to avoid excessive requests and UI thrashing.
8. Avoid layout shift. Reserve space for images (set dimensions), fonts (use next/font), and dynamic content (set min-height). Users should never lose their scroll position because content shifted.
9. Cache aggressively, invalidate precisely. Use SWR, TanStack Query, or Next.js caching to show stale data instantly while revalidating in the background. Users see content immediately.
10. Provide instant feedback for every interaction. Hover states on buttons, active states on links, focus rings on inputs, selection highlights on toggles. Every click and hover should produce visible feedback.
Error Handling & Recovery (Rules 11-18)
11. Show inline errors, not alert boxes. Display validation errors next to the relevant field. Never use alert() or generic toast for form validation.
<div>
<label htmlFor="email">Email</label>
<input id="email" aria-invalid={!!errors.email} aria-describedby="email-error" />
{errors.email && (
<p id="email-error" className="text-sm text-red-600">{errors.email}</p>
)}
</div>12. Make errors recoverable. Every error state should include a way forward: a retry button, a link to go back, or a suggestion for what to try next.
13. Preserve user input on errors. If form submission fails, never clear the form. Keep all entered data and highlight only the fields that need correction.
14. Use toast notifications for background operations. Save confirmations, async task completions, and non-blocking errors belong in toasts. Keep them brief (under 5 seconds) with an action when relevant.
15. Handle empty states thoughtfully. An empty list is not an error. Show a helpful message with a call to action.
{items.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No projects yet</p>
<Button onClick={onCreate}>Create your first project</Button>
</div>
) : (
<ProjectList items={items} />
)}16. Show confirmation for destructive actions. Delete, cancel subscription, remove team member. These need a confirmation dialog with clear consequences stated.
17. Provide undo instead of confirmation when possible. "Message deleted. Undo" is better UX than "Are you sure you want to delete?" The user can proceed faster, and mistakes are recoverable.
18. Handle offline gracefully. Detect offline state with navigator.onLine and the online/offline events. Show a banner, queue mutations, and sync when reconnected.
Accessibility (Rules 19-25)
19. Use semantic HTML first. button for actions, a for navigation, nav for navigation, main for main content, h1-h6 in order. Semantic HTML is accessible by default.
20. Every interactive element must be keyboard accessible. Tab order should be logical. Enter/Space activates buttons. Escape closes modals. Arrow keys navigate menus. Test without a mouse.
21. Every image needs an alt attribute. Descriptive alt for content images. Empty alt="" for decorative images. Never skip the attribute.
| Image Type | Alt Text |
|---|---|
| Content (photo, chart) | Describe what the image shows |
| Decorative (background, divider) | alt="" (empty string) |
| Functional (icon button) | Describe the action: "Close menu" |
22. Color alone must not convey meaning. Error states need icons or text in addition to red color. Success states need more than green. Consider colorblind users (8% of men).
23. Manage focus on route changes and modals. When a modal opens, focus the first focusable element inside. When it closes, return focus to the trigger. On client-side navigation, focus the main content or heading.
24. Announce dynamic content changes. Use aria-live="polite" for status updates (toast notifications, search result counts) so screen readers announce them.
<div aria-live="polite" aria-atomic="true">
{results.length} results found
</div>25. Label all form controls. Every input needs a visible label or aria-label. Placeholder text is not a label. Associate labels with htmlFor matching the input id.
Interaction Design (Rules 26-30)
26. Make clickable areas large enough. Touch targets should be at least 44x44px (WCAG). Small buttons and links frustrate mobile users.
// Good: padded button
<button className="px-4 py-3 min-h-[44px]">Save</button>
// Bad: tiny target
<button className="px-1 py-0.5 text-xs">x</button>27. Show loading states in context. A spinner inside the button that was clicked is better than a full-page loading screen. Show progress where the user is looking.
28. Use progressive disclosure. Do not overwhelm users with every option at once. Show essentials first, reveal advanced options on demand (accordion, "Show more," tabs).
<div>
<BasicSettings />
<details>
<summary className="cursor-pointer text-sm text-blue-600">
Advanced settings
</summary>
<AdvancedSettings />
</details>
</div>29. Respect user preferences. Honor prefers-reduced-motion for animations, prefers-color-scheme for dark mode, and prefers-contrast for high contrast. These are system-level accessibility settings.
// CSS
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}// React hook
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");30. Be consistent. Same actions should look the same everywhere. If "Save" is a blue button on one page, it should be a blue button on every page. Consistent placement, consistent style, consistent behavior. Consistency reduces cognitive load.
FAQs
What is the difference between useOptimistic and useTransition for UX?
useOptimisticshows an immediate, assumed-successful result while a Server Action runs (rolls back on failure)useTransitionkeeps the current UI interactive while a non-urgent state update renders in the background- Use
useOptimisticfor mutations (like, save); useuseTransitionfor expensive filtering or navigation
How do you show a pending state on a form submit button?
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending} type="submit">
{pending ? "Saving..." : "Save"}
</button>
);
}useFormStatus reads the pending state of the nearest parent <form>.
Why should you use inline errors instead of alert boxes for form validation?
- Inline errors appear next to the field that needs correction, reducing cognitive load
alert()blocks the thread and provides no context about which field failed- Inline errors work with
aria-describedbyfor screen reader accessibility
Gotcha: What happens if you clear form inputs on a failed submission?
- Users lose all their entered data and must retype everything
- This creates frustration and increases abandonment rates
- Always preserve user input on errors and highlight only the fields that need correction
When should you use undo instead of a confirmation dialog?
- Undo is better UX for reversible actions (delete message, archive item, remove tag)
- Confirmation dialogs slow users down and are often dismissed without reading
- Reserve confirmation dialogs for truly destructive, irreversible actions (delete account, cancel subscription)
What is the minimum touch target size and why does it matter?
- WCAG recommends at least 44x44 pixels for interactive elements
- Small targets cause mis-taps on mobile devices, frustrating users
- Use adequate padding (
px-4 py-3 min-h-[44px]) even on small buttons
How do you announce dynamic content changes to screen readers?
<div aria-live="polite" aria-atomic="true">
{results.length} results found
</div>Use aria-live="polite" for non-urgent updates (search counts, toasts) so the screen reader announces them after the current speech.
How should you handle focus management when a modal opens and closes?
- When modal opens: move focus to the first focusable element inside the modal
- Trap focus within the modal (Tab should cycle through modal elements only)
- When modal closes: return focus to the element that triggered the modal
Gotcha: Why is placeholder text not a substitute for a label?
- Placeholder text disappears as soon as the user starts typing, removing context
- Screen readers may not reliably announce placeholder text as a label
- Always use a visible
<label>withhtmlFororaria-labelon the input
How do you type the useMediaQuery hook for respecting user preferences in TypeScript?
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}How do you type the props for an error boundary fallback component in TypeScript?
type ErrorFallbackProps = {
error: Error;
resetErrorBoundary: () => void;
};
function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
return (
<div role="alert">
<p>Something went wrong: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}What CSS media queries should you respect for accessibility?
prefers-reduced-motion: reduce-- disable or minimize animationsprefers-color-scheme: dark-- offer a dark modeprefers-contrast: more-- increase contrast for low-vision users- These are system-level settings that users depend on
Quick Reference
| Category | Key Rule |
|---|---|
| Speed | Show something immediately. Optimistic updates. Stream progressively. |
| Errors | Inline errors. Preserve input. Always offer recovery. |
| Accessibility | Semantic HTML. Keyboard accessible. Focus management. |
| Feedback | Pending states on buttons. Toast for background ops. Undo over confirm. |
| Touch | 44px minimum targets. Context-aware loading. Progressive disclosure. |