React UI Patterns
Patterns for building robust UI components that handle loading, error, empty, and success states.
When to Use
-
Building components that fetch or mutate data
-
Handling async UI state transitions
-
Implementing form submissions
-
Reviewing UI for missing states
Core Principles
-
Never show stale UI — Loading indicators only when actually loading
-
Always surface errors — Users must know when something fails
-
Optimistic updates — Make the UI feel instant where safe
-
Progressive disclosure — Show content as it becomes available
-
Graceful degradation — Partial data is better than no data
Loading States
The Golden Rule
Show loading indicator ONLY when there's no data to display.
const { data, loading, error } = useQuery(GET_ITEMS);
if (error) return <ErrorState error={error} onRetry={refetch} />; if (loading && !data) return <LoadingSkeleton />; if (!data?.items.length) return <EmptyState />;
return <ItemList items={data.items} />;
// WRONG — flashes spinner on refetch when cached data exists if (loading) return <Spinner />;
// CORRECT — only show loading when no cached data if (loading && !data) return <Spinner />;
Decision Tree
Error? ──> Yes ──> Show error state with retry │ No │ Loading AND no data? ──> Yes ──> Show skeleton/spinner │ No │ Has data? ──> Yes, with items ──> Show data │ Yes, empty ──────> Show empty state No ──────────────────────────> Show loading (fallback)
Skeleton vs Spinner
Use Skeleton Use Spinner
Known content shape (lists, cards) Unknown content shape
Initial page load Modal/dialog actions
Content placeholders Button submissions
Dashboard layouts Inline operations
Error Handling
Error Hierarchy
Level When Example
Inline Field-level validation "Email is required" under input
Toast Recoverable, user can retry "Failed to save — try again"
Banner Page-level, partial data usable "Some data couldn't load"
Full screen Unrecoverable, needs action "Session expired — sign in"
Always Surface Errors
// CORRECT — error shown to user const [createItem] = useMutation(CREATE_ITEM, { onCompleted: () => toast.success('Item created'), onError: (error) => { console.error('createItem failed:', error); toast.error('Failed to create item'); }, });
// WRONG — error swallowed silently const [createItem] = useMutation(CREATE_ITEM, { onError: (error) => console.error(error), // User sees nothing! });
Error State Component
interface ErrorStateProps { error: Error; onRetry?: () => void; title?: string; }
function ErrorState({ error, onRetry, title }: ErrorStateProps) { return ( <div role="alert" className="error-state"> <AlertCircleIcon /> <h3>{title ?? 'Something went wrong'}</h3> <p>{error.message}</p> {onRetry && <Button onClick={onRetry}>Try Again</Button>} </div> ); }
Button States
Loading State
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting} isLoading={isSubmitting}
Submit </Button>
Critical Rule
Always disable buttons during async operations.
// CORRECT — disabled and shows loading <Button disabled={isSubmitting} isLoading={isSubmitting} onClick={submit}> Submit </Button>
// WRONG — user can click multiple times <Button onClick={submit}> {isSubmitting ? 'Submitting...' : 'Submit'} </Button>
Empty States
Every list or collection MUST have an empty state.
// WRONG — blank screen when no items <FlatList data={items} renderItem={renderItem} />
// CORRECT — explicit empty state {items.length === 0 ? ( <EmptyState icon={<PlusCircleIcon />} title="No items yet" description="Create your first item to get started" action={{ label: 'Create Item', onClick: handleCreate }} /> ) : ( <ItemList items={items} /> )}
Contextual Empty States
Context Icon Title Action
Search no results Search "No results found" "Try different terms"
Empty collection PlusCircle "No items yet" "Create Item" button
Filtered empty Filter "No matches" "Clear filters" button
Error empty AlertTriangle "Couldn't load" "Retry" button
Form Submission
function CreateItemForm() { const [submit, { loading }] = useMutation(CREATE_ITEM, { onCompleted: () => { toast.success('Item created'); router.push('/items'); }, onError: (error) => { console.error('Create failed:', error); toast.error('Failed to create item'); }, });
const handleSubmit = async (values: FormValues) => { if (!isValid) { toast.error('Please fix errors before submitting'); return; } await submit({ variables: { input: values } }); };
return ( <form onSubmit={handleSubmit}> <Input name="name" value={values.name} onChange={handleChange} error={touched.name ? errors.name : undefined} /> <Button type="submit" disabled={!isValid || loading} isLoading={loading} > Create Item </Button> </form> ); }
Anti-Patterns
Loading
// WRONG — spinner when cached data exists (causes flash) if (loading) return <Spinner />;
// CORRECT — only show loading without data if (loading && !data) return <Spinner />;
Errors
// WRONG — error swallowed try { await mutation(); } catch (e) { console.log(e); }
// CORRECT — error surfaced onError: (error) => { console.error('operation failed:', error); toast.error('Operation failed'); }
Buttons
// WRONG — not disabled during submission <Button onClick={submit}>Submit</Button>
// CORRECT — disabled and loading <Button onClick={submit} disabled={loading} isLoading={loading}>Submit</Button>
UI State Checklist
Before shipping any UI component:
States:
-
Error state handled and shown to user
-
Loading state shown only when no data exists
-
Empty state provided for collections
-
Buttons disabled during async operations
-
Buttons show loading indicator
Data:
-
Mutations have onError handler
-
All user actions have feedback (toast / visual change)
-
Optimistic updates where appropriate
-
Stale data doesn't flash on refetch