React UI Patterns
Core Principles
-
Never show stale UI - Loading spinners only when actually loading
-
Always surface errors - Users must know when something fails
-
Optimistic updates - Make the UI feel instant
-
Progressive disclosure - Show content as it becomes available
-
Graceful degradation - Partial data is better than no data
Loading State Patterns
The Golden Rule
Show loading indicator ONLY when there's no data to display.
// CORRECT - Only show loading when no data exists const { data, loading, error } = useGetItemsQuery();
if (error) return <ErrorState error={error} onRetry={refetch} />; if (loading && !data) return <LoadingState />; if (!data?.items.length) return <EmptyState />;
return <ItemList items={data.items} />;
// WRONG - Shows spinner even when we have cached data if (loading) return <LoadingState />; // Flashes on refetch!
Loading State Decision Tree
Is there an error? → Yes: Show error state with retry option → No: Continue
Is it loading AND we have no data? → Yes: Show loading indicator (spinner/skeleton) → No: Continue
Do we have data? → Yes, with items: Show the data → Yes, but empty: Show empty state → No: Show loading (fallback)
Skeleton vs Spinner
Use Skeleton When Use Spinner When
Known content shape Unknown content shape
List/card layouts Modal actions
Initial page load Button submissions
Content placeholders Inline operations
Error Handling Patterns
The Error Handling Hierarchy
- Inline error (field-level) → Form validation errors
- Toast notification → Recoverable errors, user can retry
- Error banner → Page-level errors, data still partially usable
- Full error screen → Unrecoverable, needs user action
Always Show Errors
CRITICAL: Never swallow errors silently.
// CORRECT - Error always surfaced to user const [createItem, { loading }] = useCreateItemMutation({ onCompleted: () => { toast.success({ title: 'Item created' }); }, onError: (error) => { console.error('createItem failed:', error); toast.error({ title: 'Failed to create item' }); }, });
// WRONG - Error silently caught, user has no idea const [createItem] = useCreateItemMutation({ onError: (error) => { console.error(error); // User sees nothing! }, });
Error State Component Pattern
interface ErrorStateProps { error: Error; onRetry?: () => void; title?: string; }
const ErrorState = ({ error, onRetry, title }: ErrorStateProps) => ( <div className="error-state"> <Icon name="exclamation-circle" /> <h3>{title ?? 'Something went wrong'}</h3> <p>{error.message}</p> {onRetry && ( <Button onClick={onRetry}>Try Again</Button> )} </div> );
Button State Patterns
Button Loading State
<Button onClick={handleSubmit} isLoading={isSubmitting} disabled={!isValid || isSubmitting}
Submit </Button>
Disable During Operations
CRITICAL: Always disable triggers during async operations.
// CORRECT - Button disabled while loading <Button disabled={isSubmitting} isLoading={isSubmitting} onClick={handleSubmit}
Submit </Button>
// WRONG - User can tap multiple times <Button onClick={handleSubmit}> {isSubmitting ? 'Submitting...' : 'Submit'} </Button>
Empty States
Empty State Requirements
Every list/collection MUST have an empty state:
// WRONG - No empty state return <FlatList data={items} />;
// CORRECT - Explicit empty state return ( <FlatList data={items} ListEmptyComponent={<EmptyState />} /> );
Contextual Empty States
// Search with no results <EmptyState icon="search" title="No results found" description="Try different search terms" />
// List with no items yet <EmptyState icon="plus-circle" title="No items yet" description="Create your first item" action={{ label: 'Create Item', onClick: handleCreate }} />
Form Submission Pattern
const MyForm = () => { const [submit, { loading }] = useSubmitMutation({ onCompleted: handleSuccess, onError: handleError, });
const handleSubmit = async () => { if (!isValid) { toast.error({ title: 'Please fix errors' }); return; } await submit({ variables: { input: values } }); };
return ( <form> <Input value={values.name} onChange={handleChange('name')} error={touched.name ? errors.name : undefined} /> <Button type="submit" onClick={handleSubmit} disabled={!isValid || loading} isLoading={loading} > Submit </Button> </form> ); };
Anti-Patterns
Loading States
// WRONG - Spinner when data exists (causes flash) if (loading) return <Spinner />;
// CORRECT - Only show loading without data if (loading && !data) return <Spinner />;
Error Handling
// WRONG - Error swallowed try { await mutation(); } catch (e) { console.log(e); // User has no idea! }
// CORRECT - Error surfaced onError: (error) => { console.error('operation failed:', error); toast.error({ title: 'Operation failed' }); }
Button States
// WRONG - Button not disabled during submission <Button onClick={submit}>Submit</Button>
// CORRECT - Disabled and shows loading <Button onClick={submit} disabled={loading} isLoading={loading}> Submit </Button>
Checklist
Before completing any UI component:
UI 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 when appropriate
Data & Mutations:
-
Mutations have onError handler
-
All user actions have feedback (toast/visual)
Integration with Other Skills
-
graphql-schema: Use mutation patterns with proper error handling
-
testing-patterns: Test all UI states (loading, error, empty, success)
-
formik-patterns: Apply form submission patterns