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.
Search across all documentation pages
Rules for building React interfaces that feel fast, forgiving, and intuitive. Covers loading states, error recovery, feedback, accessibility, and interaction design.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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.
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.
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.
useOptimistic shows an immediate, assumed-successful result while a Server Action runs (rolls back on failure)useTransition keeps the current UI interactive while a non-urgent state update renders in the backgrounduseOptimistic for mutations (like, save); use useTransition for expensive filtering or navigationfunction SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending} type="submit">
{pending ? "Saving..." : "Save"}
</button>
);
}useFormStatus reads the pending state of the nearest parent <form>.
alert() blocks the thread and provides no context about which field failedaria-describedby for screen reader accessibilitypx-4 py-3 min-h-[44px]) even on small buttons<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.
<label> with htmlFor or aria-label on the inputfunction 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;
}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>
);
}prefers-reduced-motion: reduce -- disable or minimize animationsprefers-color-scheme: dark -- offer a dark modeprefers-contrast: more -- increase contrast for low-vision users| 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. |
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥