3661 lines
80 KiB
Markdown
3661 lines
80 KiB
Markdown
# Frontend Development
|
||
|
||
**Comprehensive patterns for professional React/Vue/Svelte development**
|
||
|
||
Consolidated from:
|
||
- frontend-developer/skills/component-development
|
||
- frontend-developer/skills/responsive-design
|
||
- frontend-developer/skills/state-management
|
||
|
||
---
|
||
|
||
# Component Development Skill
|
||
|
||
**Production-tested patterns for building professional React/Vue/Svelte components**
|
||
|
||
This skill codifies best practices from thousands of production component implementations across modern frontend frameworks.
|
||
|
||
---
|
||
|
||
## Core Principles
|
||
|
||
1. **Type Safety First**: All components strongly typed with TypeScript
|
||
2. **Accessibility by Default**: WCAG 2.1 AA compliance is mandatory
|
||
3. **Performance Matters**: Optimize render cycles and bundle size
|
||
4. **Test Everything**: 80%+ coverage with meaningful tests
|
||
5. **Document Clearly**: Code should be self-documenting with helpful comments
|
||
|
||
---
|
||
|
||
## Component Architecture Patterns
|
||
|
||
### Single Responsibility Principle
|
||
|
||
Each component should do ONE thing well:
|
||
|
||
```tsx
|
||
// ❌ BAD: Component does too much
|
||
function UserDashboard() {
|
||
// Handles auth, fetches data, renders UI, manages state, etc.
|
||
}
|
||
|
||
// ✅ GOOD: Split responsibilities
|
||
function UserDashboard() {
|
||
return (
|
||
<DashboardLayout>
|
||
<UserProfile />
|
||
<UserStats />
|
||
<UserActivity />
|
||
</DashboardLayout>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Container vs Presentational Pattern
|
||
|
||
**Container Components** (Smart):
|
||
- Handle business logic
|
||
- Manage state
|
||
- Fetch data
|
||
- Connect to stores
|
||
|
||
```tsx
|
||
// containers/UserProfileContainer.tsx
|
||
export function UserProfileContainer() {
|
||
const user = useUserStore(selectUser);
|
||
const updateUser = useUserStore((state) => state.updateUser);
|
||
|
||
return <UserProfile user={user} onUpdate={updateUser} />;
|
||
}
|
||
```
|
||
|
||
**Presentational Components** (Dumb):
|
||
- Receive data via props
|
||
- Render UI
|
||
- No state management
|
||
- Highly reusable
|
||
|
||
```tsx
|
||
// components/UserProfile.tsx
|
||
interface UserProfileProps {
|
||
user: User;
|
||
onUpdate: (user: User) => void;
|
||
}
|
||
|
||
export function UserProfile({ user, onUpdate }: UserProfileProps) {
|
||
return <div>{user.name}</div>;
|
||
}
|
||
```
|
||
|
||
### Compound Components Pattern
|
||
|
||
For complex, related components:
|
||
|
||
```tsx
|
||
// components/Tabs/Tabs.tsx
|
||
export function Tabs({ children }: { children: ReactNode }) {
|
||
const [activeTab, setActiveTab] = useState(0);
|
||
|
||
return (
|
||
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
||
<div className={styles.tabs}>{children}</div>
|
||
</TabsContext.Provider>
|
||
);
|
||
}
|
||
|
||
Tabs.List = TabsList;
|
||
Tabs.Tab = Tab;
|
||
Tabs.Panel = TabPanel;
|
||
|
||
// Usage
|
||
<Tabs>
|
||
<Tabs.List>
|
||
<Tabs.Tab>Tab 1</Tabs.Tab>
|
||
<Tabs.Tab>Tab 2</Tabs.Tab>
|
||
</Tabs.List>
|
||
<Tabs.Panel>Content 1</Tabs.Panel>
|
||
<Tabs.Panel>Content 2</Tabs.Panel>
|
||
</Tabs>
|
||
```
|
||
|
||
---
|
||
|
||
## React Component Template (TypeScript)
|
||
|
||
```tsx
|
||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import styles from './ComponentName.module.css';
|
||
|
||
/**
|
||
* Props interface with full documentation
|
||
*/
|
||
export interface ComponentNameProps {
|
||
/**
|
||
* Required prop description
|
||
*/
|
||
requiredProp: string;
|
||
|
||
/**
|
||
* Optional prop with default value
|
||
* @default false
|
||
*/
|
||
optionalProp?: boolean;
|
||
|
||
/**
|
||
* Event handler for user action
|
||
*/
|
||
onAction?: (data: ActionData) => void;
|
||
|
||
/**
|
||
* Child elements
|
||
*/
|
||
children?: React.ReactNode;
|
||
|
||
/**
|
||
* Additional CSS classes
|
||
*/
|
||
className?: string;
|
||
|
||
/**
|
||
* ARIA label for accessibility
|
||
*/
|
||
ariaLabel?: string;
|
||
}
|
||
|
||
/**
|
||
* ComponentName - Brief one-line description
|
||
*
|
||
* Longer description explaining what this component does,
|
||
* when to use it, and any important considerations.
|
||
*
|
||
* @example
|
||
* ```tsx
|
||
* <ComponentName
|
||
* requiredProp="value"
|
||
* onAction={(data) => console.log(data)}
|
||
* >
|
||
* Child content
|
||
* </ComponentName>
|
||
* ```
|
||
*/
|
||
export const ComponentName = React.forwardRef<
|
||
HTMLDivElement,
|
||
ComponentNameProps
|
||
>(
|
||
(
|
||
{
|
||
requiredProp,
|
||
optionalProp = false,
|
||
onAction,
|
||
children,
|
||
className,
|
||
ariaLabel,
|
||
},
|
||
ref
|
||
) => {
|
||
// State
|
||
const [localState, setLocalState] = useState<string>('');
|
||
|
||
// Memoized values (expensive calculations)
|
||
const computedValue = useMemo(() => {
|
||
return expensiveCalculation(requiredProp);
|
||
}, [requiredProp]);
|
||
|
||
// Callbacks (prevent re-creating on every render)
|
||
const handleClick = useCallback(() => {
|
||
if (onAction) {
|
||
onAction({ data: 'value' });
|
||
}
|
||
}, [onAction]);
|
||
|
||
// Effects
|
||
useEffect(() => {
|
||
// Setup
|
||
const cleanup = setupSomething();
|
||
|
||
// Cleanup
|
||
return () => {
|
||
cleanup();
|
||
};
|
||
}, [requiredProp]);
|
||
|
||
// Render
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
className={`${styles.container} ${className || ''}`}
|
||
role="region"
|
||
aria-label={ariaLabel || 'Component description'}
|
||
>
|
||
<h2 className={styles.title}>{requiredProp}</h2>
|
||
{optionalProp && <div className={styles.optional}>Optional content</div>}
|
||
<button
|
||
className={styles.button}
|
||
onClick={handleClick}
|
||
aria-label="Action button"
|
||
>
|
||
Click me
|
||
</button>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
);
|
||
|
||
ComponentName.displayName = 'ComponentName';
|
||
|
||
export default ComponentName;
|
||
```
|
||
|
||
---
|
||
|
||
## Vue 3 Component Template (Composition API + TypeScript)
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||
|
||
/**
|
||
* Props interface
|
||
*/
|
||
export interface Props {
|
||
/**
|
||
* Required prop description
|
||
*/
|
||
requiredProp: string;
|
||
|
||
/**
|
||
* Optional prop with default
|
||
*/
|
||
optionalProp?: boolean;
|
||
}
|
||
|
||
// Props with defaults
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
optionalProp: false,
|
||
});
|
||
|
||
// Emits
|
||
const emit = defineEmits<{
|
||
action: [data: ActionData];
|
||
update: [value: string];
|
||
}>();
|
||
|
||
// State
|
||
const localState = ref<string>('');
|
||
const isActive = ref<boolean>(false);
|
||
|
||
// Computed
|
||
const computedValue = computed(() => {
|
||
return expensiveCalculation(props.requiredProp);
|
||
});
|
||
|
||
// Methods
|
||
function handleClick() {
|
||
emit('action', { data: 'value' });
|
||
}
|
||
|
||
// Watch
|
||
watch(
|
||
() => props.requiredProp,
|
||
(newValue, oldValue) => {
|
||
console.log(`Changed from ${oldValue} to ${newValue}`);
|
||
}
|
||
);
|
||
|
||
// Lifecycle
|
||
onMounted(() => {
|
||
// Setup
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
// Cleanup
|
||
});
|
||
|
||
// Expose for template refs
|
||
defineExpose({
|
||
localState,
|
||
handleClick,
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="component-name"
|
||
role="region"
|
||
:aria-label="ariaLabel || 'Component description'"
|
||
>
|
||
<h2 class="component-name__title">{{ requiredProp }}</h2>
|
||
|
||
<div v-if="optionalProp" class="component-name__optional">
|
||
Optional content
|
||
</div>
|
||
|
||
<button
|
||
class="component-name__button"
|
||
@click="handleClick"
|
||
aria-label="Action button"
|
||
>
|
||
Click me
|
||
</button>
|
||
|
||
<slot />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.component-name {
|
||
padding: 1rem;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 0.5rem;
|
||
}
|
||
|
||
.component-name__title {
|
||
font-size: 1.5rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.component-name__button {
|
||
padding: 0.5rem 1rem;
|
||
background: var(--primary-color);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 0.25rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.component-name__button:hover {
|
||
background: var(--primary-color-dark);
|
||
}
|
||
|
||
.component-name__button:focus {
|
||
outline: 2px solid var(--focus-color);
|
||
outline-offset: 2px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
## Svelte Component Template (TypeScript)
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import { writable, derived } from 'svelte/store';
|
||
|
||
// Props
|
||
export let requiredProp: string;
|
||
export let optionalProp: boolean = false;
|
||
|
||
// Events
|
||
import { createEventDispatcher } from 'svelte';
|
||
const dispatch = createEventDispatcher<{
|
||
action: ActionData;
|
||
update: string;
|
||
}>();
|
||
|
||
// State
|
||
let localState = '';
|
||
let isActive = false;
|
||
|
||
// Reactive declarations (computed values)
|
||
$: computedValue = expensiveCalculation(requiredProp);
|
||
|
||
// Methods
|
||
function handleClick() {
|
||
dispatch('action', { data: 'value' });
|
||
}
|
||
|
||
// Reactive statements (like watch)
|
||
$: {
|
||
if (requiredProp) {
|
||
console.log('Required prop changed:', requiredProp);
|
||
}
|
||
}
|
||
|
||
// Lifecycle
|
||
onMount(() => {
|
||
// Setup
|
||
return () => {
|
||
// Cleanup (returned function runs on destroy)
|
||
};
|
||
});
|
||
|
||
onDestroy(() => {
|
||
// Additional cleanup
|
||
});
|
||
</script>
|
||
|
||
<div
|
||
class="component-name"
|
||
role="region"
|
||
aria-label="Component description"
|
||
>
|
||
<h2 class="component-name__title">{requiredProp}</h2>
|
||
|
||
{#if optionalProp}
|
||
<div class="component-name__optional">Optional content</div>
|
||
{/if}
|
||
|
||
<button
|
||
class="component-name__button"
|
||
on:click={handleClick}
|
||
aria-label="Action button"
|
||
>
|
||
Click me
|
||
</button>
|
||
|
||
<slot />
|
||
</div>
|
||
|
||
<style>
|
||
.component-name {
|
||
padding: 1rem;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 0.5rem;
|
||
}
|
||
|
||
.component-name__title {
|
||
font-size: 1.5rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.component-name__button {
|
||
padding: 0.5rem 1rem;
|
||
background: var(--primary-color);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 0.25rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.component-name__button:hover {
|
||
background: var(--primary-color-dark);
|
||
}
|
||
|
||
.component-name__button:focus {
|
||
outline: 2px solid var(--focus-color);
|
||
outline-offset: 2px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
## Performance Optimization Patterns
|
||
|
||
### 1. Memoization (React)
|
||
|
||
```tsx
|
||
// useMemo for expensive calculations
|
||
const expensiveValue = useMemo(() => {
|
||
return items.filter(item => item.active).map(item => item.value);
|
||
}, [items]);
|
||
|
||
// useCallback for functions passed as props
|
||
const handleClick = useCallback((id: string) => {
|
||
dispatch(deleteItem(id));
|
||
}, [dispatch]);
|
||
|
||
// React.memo for component memoization
|
||
export const ExpensiveComponent = React.memo(({ data }: Props) => {
|
||
return <div>{/* Expensive render */}</div>;
|
||
});
|
||
|
||
// Custom comparison function for React.memo
|
||
export const Component = React.memo(
|
||
({ data }: Props) => <div>{data.name}</div>,
|
||
(prevProps, nextProps) => {
|
||
// Return true if props are equal (skip re-render)
|
||
return prevProps.data.id === nextProps.data.id;
|
||
}
|
||
);
|
||
```
|
||
|
||
### 2. Code Splitting and Lazy Loading
|
||
|
||
```tsx
|
||
// Lazy load heavy components
|
||
const HeavyChart = React.lazy(() => import('./HeavyChart'));
|
||
const AdminPanel = React.lazy(() => import('./AdminPanel'));
|
||
|
||
function Dashboard() {
|
||
return (
|
||
<Suspense fallback={<Spinner />}>
|
||
<HeavyChart data={data} />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
// Lazy load on interaction
|
||
function App() {
|
||
const [showModal, setShowModal] = useState(false);
|
||
|
||
return (
|
||
<>
|
||
<button onClick={() => setShowModal(true)}>Open Modal</button>
|
||
{showModal && (
|
||
<Suspense fallback={<div>Loading...</div>}>
|
||
<Modal onClose={() => setShowModal(false)} />
|
||
</Suspense>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 3. Virtualization for Long Lists
|
||
|
||
```tsx
|
||
import { FixedSizeList } from 'react-window';
|
||
|
||
function LongList({ items }: { items: Item[] }) {
|
||
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
|
||
<div style={style}>
|
||
<ItemComponent item={items[index]} />
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<FixedSizeList
|
||
height={600}
|
||
itemCount={items.length}
|
||
itemSize={50}
|
||
width="100%"
|
||
>
|
||
{Row}
|
||
</FixedSizeList>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4. Debouncing and Throttling
|
||
|
||
```tsx
|
||
import { useDebouncedCallback } from 'use-debounce';
|
||
|
||
function SearchInput() {
|
||
const [search, setSearch] = useState('');
|
||
|
||
// Debounce search (wait 300ms after user stops typing)
|
||
const debouncedSearch = useDebouncedCallback(
|
||
(value: string) => {
|
||
performSearch(value);
|
||
},
|
||
300
|
||
);
|
||
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value;
|
||
setSearch(value);
|
||
debouncedSearch(value);
|
||
};
|
||
|
||
return <input value={search} onChange={handleChange} />;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Accessibility Patterns
|
||
|
||
### Semantic HTML
|
||
|
||
```tsx
|
||
// ✅ GOOD: Semantic HTML
|
||
<nav>
|
||
<ul>
|
||
<li><a href="/">Home</a></li>
|
||
<li><a href="/about">About</a></li>
|
||
</ul>
|
||
</nav>
|
||
|
||
<main>
|
||
<article>
|
||
<header>
|
||
<h1>Article Title</h1>
|
||
<time dateTime="2025-01-20">January 20, 2025</time>
|
||
</header>
|
||
<p>Article content...</p>
|
||
</article>
|
||
</main>
|
||
|
||
<footer>
|
||
<p>© 2025 Company</p>
|
||
</footer>
|
||
|
||
// ❌ BAD: Divs everywhere
|
||
<div className="nav">
|
||
<div className="nav-list">
|
||
<div className="nav-item"><div className="link">Home</div></div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### ARIA Attributes
|
||
|
||
```tsx
|
||
// Button (already accessible, no ARIA needed)
|
||
<button onClick={handleClick}>Click me</button>
|
||
|
||
// Custom button (needs ARIA)
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={handleClick}
|
||
onKeyPress={(e) => e.key === 'Enter' && handleClick()}
|
||
aria-label="Custom button"
|
||
>
|
||
Click me
|
||
</div>
|
||
|
||
// Modal
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="modal-title"
|
||
aria-describedby="modal-description"
|
||
>
|
||
<h2 id="modal-title">Modal Title</h2>
|
||
<p id="modal-description">Modal description</p>
|
||
</div>
|
||
|
||
// Loading state
|
||
<div role="status" aria-live="polite" aria-busy="true">
|
||
Loading...
|
||
</div>
|
||
|
||
// Alert
|
||
<div role="alert" aria-live="assertive">
|
||
Error: Something went wrong
|
||
</div>
|
||
```
|
||
|
||
### Keyboard Navigation
|
||
|
||
```tsx
|
||
function Dropdown() {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
switch (e.key) {
|
||
case 'Enter':
|
||
case ' ':
|
||
setIsOpen(!isOpen);
|
||
break;
|
||
case 'Escape':
|
||
setIsOpen(false);
|
||
break;
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
setFocusedIndex((prev) => (prev + 1) % items.length);
|
||
break;
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
setFocusedIndex((prev) => (prev - 1 + items.length) % items.length);
|
||
break;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
role="combobox"
|
||
aria-expanded={isOpen}
|
||
aria-haspopup="listbox"
|
||
onKeyDown={handleKeyDown}
|
||
>
|
||
{/* Dropdown content */}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Focus Management
|
||
|
||
```tsx
|
||
function Modal({ isOpen, onClose }: ModalProps) {
|
||
const modalRef = useRef<HTMLDivElement>(null);
|
||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
// Store previously focused element
|
||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||
|
||
// Focus modal
|
||
modalRef.current?.focus();
|
||
|
||
// Trap focus within modal
|
||
const handleTab = (e: KeyboardEvent) => {
|
||
if (e.key === 'Tab') {
|
||
const focusableElements = modalRef.current?.querySelectorAll(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
if (!focusableElements) return;
|
||
|
||
const firstElement = focusableElements[0] as HTMLElement;
|
||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||
|
||
if (e.shiftKey && document.activeElement === firstElement) {
|
||
e.preventDefault();
|
||
lastElement.focus();
|
||
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
||
e.preventDefault();
|
||
firstElement.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
document.addEventListener('keydown', handleTab);
|
||
|
||
return () => {
|
||
document.removeEventListener('keydown', handleTab);
|
||
|
||
// Restore focus
|
||
previousFocusRef.current?.focus();
|
||
};
|
||
}
|
||
}, [isOpen]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div
|
||
ref={modalRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
tabIndex={-1}
|
||
>
|
||
{/* Modal content */}
|
||
<button onClick={onClose}>Close</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Testing Patterns
|
||
|
||
### Unit Tests (React Testing Library)
|
||
|
||
```typescript
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { ComponentName } from './ComponentName';
|
||
|
||
describe('ComponentName', () => {
|
||
// Basic rendering
|
||
it('renders correctly with required props', () => {
|
||
render(<ComponentName requiredProp="test" />);
|
||
expect(screen.getByText('test')).toBeInTheDocument();
|
||
});
|
||
|
||
// Props handling
|
||
it('applies optional prop correctly', () => {
|
||
render(<ComponentName requiredProp="test" optionalProp={true} />);
|
||
expect(screen.getByText('Optional content')).toBeInTheDocument();
|
||
});
|
||
|
||
// Event handling
|
||
it('calls onAction when button clicked', () => {
|
||
const mockAction = jest.fn();
|
||
render(<ComponentName requiredProp="test" onAction={mockAction} />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Action button' }));
|
||
expect(mockAction).toHaveBeenCalledWith({ data: 'value' });
|
||
});
|
||
|
||
// Keyboard interaction
|
||
it('handles keyboard navigation', async () => {
|
||
const user = userEvent.setup();
|
||
render(<ComponentName requiredProp="test" />);
|
||
|
||
const button = screen.getByRole('button');
|
||
await user.tab();
|
||
expect(button).toHaveFocus();
|
||
|
||
await user.keyboard('{Enter}');
|
||
// Assert button action occurred
|
||
});
|
||
|
||
// Accessibility
|
||
it('has correct ARIA attributes', () => {
|
||
render(<ComponentName requiredProp="test" ariaLabel="Custom label" />);
|
||
|
||
const region = screen.getByRole('region');
|
||
expect(region).toHaveAttribute('aria-label', 'Custom label');
|
||
});
|
||
|
||
// Async behavior
|
||
it('fetches data on mount', async () => {
|
||
render(<ComponentName requiredProp="test" />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Loaded data')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// Error states
|
||
it('displays error message on failure', async () => {
|
||
// Mock fetch to return error
|
||
global.fetch = jest.fn(() =>
|
||
Promise.reject(new Error('Network error'))
|
||
);
|
||
|
||
render(<ComponentName requiredProp="test" />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### Integration Tests
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||
import { UserProvider } from '@/contexts/UserContext';
|
||
import App from './App';
|
||
|
||
function renderWithProviders(ui: React.ReactElement, { initialRoute = '/' } = {}) {
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: {
|
||
queries: { retry: false },
|
||
},
|
||
});
|
||
|
||
return render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<UserProvider>
|
||
<MemoryRouter initialEntries={[initialRoute]}>
|
||
<Routes>
|
||
<Route path="*" element={ui} />
|
||
</Routes>
|
||
</MemoryRouter>
|
||
</UserProvider>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
describe('App Integration', () => {
|
||
it('renders dashboard for authenticated user', async () => {
|
||
renderWithProviders(<App />, { initialRoute: '/dashboard' });
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Welcome back')).toBeInTheDocument();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### Accessibility Tests
|
||
|
||
```typescript
|
||
import { axe, toHaveNoViolations } from 'jest-axe';
|
||
|
||
expect.extend(toHaveNoViolations);
|
||
|
||
describe('ComponentName Accessibility', () => {
|
||
it('should not have accessibility violations', async () => {
|
||
const { container } = render(<ComponentName requiredProp="test" />);
|
||
const results = await axe(container);
|
||
expect(results).toHaveNoViolations();
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Error Handling Patterns
|
||
|
||
### Error Boundaries (React)
|
||
|
||
```tsx
|
||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||
|
||
interface Props {
|
||
children: ReactNode;
|
||
fallback?: ReactNode;
|
||
}
|
||
|
||
interface State {
|
||
hasError: boolean;
|
||
error: Error | null;
|
||
}
|
||
|
||
export class ErrorBoundary extends Component<Props, State> {
|
||
public state: State = {
|
||
hasError: false,
|
||
error: null,
|
||
};
|
||
|
||
public static getDerivedStateFromError(error: Error): State {
|
||
return { hasError: true, error };
|
||
}
|
||
|
||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||
console.error('Error caught by boundary:', error, errorInfo);
|
||
// Log to error reporting service
|
||
}
|
||
|
||
public render() {
|
||
if (this.state.hasError) {
|
||
return (
|
||
this.props.fallback || (
|
||
<div role="alert">
|
||
<h2>Something went wrong</h2>
|
||
<details>
|
||
<summary>Error details</summary>
|
||
<pre>{this.state.error?.message}</pre>
|
||
</details>
|
||
</div>
|
||
)
|
||
);
|
||
}
|
||
|
||
return this.props.children;
|
||
}
|
||
}
|
||
|
||
// Usage
|
||
<ErrorBoundary>
|
||
<MyComponent />
|
||
</ErrorBoundary>
|
||
```
|
||
|
||
---
|
||
|
||
## File Organization
|
||
|
||
```
|
||
src/
|
||
├── components/
|
||
│ ├── Button/
|
||
│ │ ├── Button.tsx # Component implementation
|
||
│ │ ├── Button.module.css # Styles
|
||
│ │ ├── Button.test.tsx # Tests
|
||
│ │ ├── Button.stories.tsx # Storybook stories
|
||
│ │ └── index.ts # Exports
|
||
│ ├── Form/
|
||
│ │ ├── Form.tsx
|
||
│ │ ├── FormField.tsx # Sub-components
|
||
│ │ ├── FormButton.tsx
|
||
│ │ ├── Form.module.css
|
||
│ │ ├── Form.test.tsx
|
||
│ │ └── index.ts
|
||
│ └── shared/ # Shared/utility components
|
||
│ ├── Spinner/
|
||
│ └── ErrorMessage/
|
||
├── containers/ # Container components
|
||
│ ├── UserDashboardContainer.tsx
|
||
│ └── ProductListContainer.tsx
|
||
├── hooks/ # Custom hooks
|
||
│ ├── useAuth.ts
|
||
│ ├── useLocalStorage.ts
|
||
│ └── useDebounce.ts
|
||
├── types/ # TypeScript types
|
||
│ ├── user.ts
|
||
│ ├── product.ts
|
||
│ └── api.ts
|
||
└── utils/ # Utility functions
|
||
├── validation.ts
|
||
└── formatting.ts
|
||
```
|
||
|
||
---
|
||
|
||
## Common Pitfalls to Avoid
|
||
|
||
### 1. Props Drilling
|
||
```tsx
|
||
// ❌ BAD: Passing props through many levels
|
||
<GrandParent data={data}>
|
||
<Parent data={data}>
|
||
<Child data={data}>
|
||
<GrandChild data={data} /> {/* Only this needs it */}
|
||
</Child>
|
||
</Parent>
|
||
</GrandParent>
|
||
|
||
// ✅ GOOD: Use Context or state management
|
||
const DataContext = createContext(data);
|
||
|
||
<GrandParent>
|
||
<DataContext.Provider value={data}>
|
||
<Parent>
|
||
<Child>
|
||
<GrandChild /> {/* Uses useContext(DataContext) */}
|
||
</Child>
|
||
</Parent>
|
||
</DataContext.Provider>
|
||
</GrandParent>
|
||
```
|
||
|
||
### 2. Inline Functions in JSX
|
||
```tsx
|
||
// ❌ BAD: Creates new function on every render
|
||
<button onClick={() => handleClick(id)}>Click</button>
|
||
|
||
// ✅ GOOD: Use useCallback
|
||
const handleButtonClick = useCallback(() => {
|
||
handleClick(id);
|
||
}, [id]);
|
||
|
||
<button onClick={handleButtonClick}>Click</button>
|
||
```
|
||
|
||
### 3. Missing Keys in Lists
|
||
```tsx
|
||
// ❌ BAD: Using index as key (breaks on reorder)
|
||
{items.map((item, index) => <Item key={index} {...item} />)}
|
||
|
||
// ✅ GOOD: Use stable unique identifier
|
||
{items.map((item) => <Item key={item.id} {...item} />)}
|
||
```
|
||
|
||
### 4. Mutating State Directly
|
||
```tsx
|
||
// ❌ BAD: Mutates state
|
||
const addItem = () => {
|
||
items.push(newItem);
|
||
setItems(items);
|
||
};
|
||
|
||
// ✅ GOOD: Creates new array
|
||
const addItem = () => {
|
||
setItems([...items, newItem]);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Summary Checklist
|
||
|
||
When creating a component, ensure:
|
||
|
||
**Type Safety**:
|
||
- [ ] Props interface defined with TypeScript
|
||
- [ ] All event handlers properly typed
|
||
- [ ] No `any` types used
|
||
- [ ] Children/slots typed correctly
|
||
|
||
**Accessibility**:
|
||
- [ ] Semantic HTML used
|
||
- [ ] ARIA attributes where needed
|
||
- [ ] Keyboard navigation works
|
||
- [ ] Focus management implemented
|
||
- [ ] Color contrast sufficient
|
||
- [ ] Screen reader tested
|
||
|
||
**Performance**:
|
||
- [ ] Memoization applied where beneficial
|
||
- [ ] No unnecessary re-renders
|
||
- [ ] Code splitting for large components
|
||
- [ ] Lists virtualized if > 100 items
|
||
|
||
**Testing**:
|
||
- [ ] Unit tests for logic
|
||
- [ ] Render tests
|
||
- [ ] Interaction tests
|
||
- [ ] Accessibility tests
|
||
- [ ] Coverage ≥ 80%
|
||
|
||
**Documentation**:
|
||
- [ ] JSDoc comments on component
|
||
- [ ] Props documented
|
||
- [ ] Usage examples provided
|
||
- [ ] Storybook story (if applicable)
|
||
|
||
**Code Quality**:
|
||
- [ ] Single responsibility
|
||
- [ ] DRY principle followed
|
||
- [ ] Error handling implemented
|
||
- [ ] Loading states handled
|
||
- [ ] Empty states handled
|
||
|
||
---
|
||
|
||
## MCP-Enhanced Development
|
||
|
||
### Context7 MCP Integration
|
||
|
||
When Context7 MCP is available, access up-to-date documentation for React, Vue, Svelte, and their ecosystems:
|
||
|
||
```typescript
|
||
// Runtime detection - no configuration needed
|
||
const hasContext7 = typeof mcp__plugin_essentials_context7__resolve_library_id !== 'undefined' &&
|
||
typeof mcp__plugin_essentials_context7__get_library_docs !== 'undefined';
|
||
|
||
if (hasContext7) {
|
||
console.log("✓ Using Context7 MCP for live framework documentation");
|
||
|
||
// Get latest React documentation
|
||
const reactLibrary = await mcp__plugin_essentials_context7__resolve_library_id({
|
||
libraryName: "react"
|
||
});
|
||
|
||
const reactDocs = await mcp__plugin_essentials_context7__get_library_docs({
|
||
context7CompatibleLibraryID: reactLibrary.id,
|
||
topic: "hooks",
|
||
tokens: 5000
|
||
});
|
||
|
||
console.log("✓ Retrieved latest React documentation");
|
||
// Use current API patterns from official docs
|
||
// - Latest hooks patterns
|
||
// - Current best practices
|
||
// - Up-to-date TypeScript types
|
||
// - Recent performance optimizations
|
||
|
||
// Example: Get library-specific patterns
|
||
const libraries = {
|
||
stateManagement: ["zustand", "redux", "jotai"],
|
||
styling: ["tailwindcss", "styled-components", "emotion"],
|
||
forms: ["react-hook-form", "formik"],
|
||
testing: ["vitest", "testing-library"]
|
||
};
|
||
|
||
// Fetch docs for specific library being used
|
||
for (const lib of libraries.stateManagement) {
|
||
const libDocs = await mcp__plugin_essentials_context7__resolve_library_id({
|
||
libraryName: lib
|
||
});
|
||
// Get latest patterns for that library
|
||
}
|
||
|
||
console.log("✓ All library documentation current and accurate");
|
||
|
||
} else {
|
||
console.log("ℹ️ Context7 MCP not available");
|
||
console.log(" Install for access to latest framework documentation:");
|
||
console.log(" npm install -g @context7/mcp-server");
|
||
console.log(" Using general knowledge from this skill");
|
||
console.log(" Note: Patterns may not reflect latest framework versions");
|
||
}
|
||
```
|
||
|
||
### Benefits Comparison
|
||
|
||
| Aspect | With Context7 MCP | Without MCP (Skill Only) |
|
||
|--------|------------------|-------------------------|
|
||
| **Documentation** | Latest official docs from source | Patterns from skill (may be outdated) |
|
||
| **API Changes** | Reflects current version | Based on LLM training data |
|
||
| **Framework Updates** | Real-time access to new features | Limited to known patterns |
|
||
| **Library Compatibility** | Current version compatibility | General compatibility guidance |
|
||
| **TypeScript Types** | Latest type definitions | Common type patterns |
|
||
| **Migration Guides** | Access to official migration docs | General migration strategies |
|
||
| **Example Code** | Current examples from docs | Skill-based examples |
|
||
|
||
**When to use Context7 MCP:**
|
||
- Working with latest framework versions
|
||
- Using newly released features
|
||
- Need current TypeScript definitions
|
||
- Following official best practices
|
||
- Migrating between major versions
|
||
- Integrating new libraries
|
||
- Resolving framework-specific bugs
|
||
|
||
**When skill knowledge sufficient:**
|
||
- Stable, well-known patterns
|
||
- Core framework concepts (unchanged)
|
||
- General architecture principles
|
||
- Common component patterns
|
||
- Universal accessibility practices
|
||
- Performance optimization basics
|
||
|
||
### Framework-Specific MCP Usage
|
||
|
||
#### React + Context7
|
||
|
||
```typescript
|
||
// Get React 19 documentation (if available)
|
||
const react19 = await mcp__plugin_essentials_context7__resolve_library_id({
|
||
libraryName: "react@19"
|
||
});
|
||
|
||
const serverComponents = await mcp__plugin_essentials_context7__get_library_docs({
|
||
context7CompatibleLibraryID: react19.id,
|
||
topic: "server-components",
|
||
tokens: 3000
|
||
});
|
||
|
||
// Use latest RSC patterns from official docs
|
||
```
|
||
|
||
#### Vue 3 + Context7
|
||
|
||
```typescript
|
||
// Get Vue 3 composition API docs
|
||
const vue3 = await mcp__plugin_essentials_context7__resolve_library_id({
|
||
libraryName: "vue@3"
|
||
});
|
||
|
||
const compositionAPI = await mcp__plugin_essentials_context7__get_library_docs({
|
||
context7CompatibleLibraryID: vue3.id,
|
||
topic: "composition-api",
|
||
tokens: 4000
|
||
});
|
||
|
||
// Use current Composition API patterns
|
||
```
|
||
|
||
#### Svelte + Context7
|
||
|
||
```typescript
|
||
// Get SvelteKit documentation
|
||
const sveltekit = await mcp__plugin_essentials_context7__resolve_library_id({
|
||
libraryName: "sveltekit"
|
||
});
|
||
|
||
const routing = await mcp__plugin_essentials_context7__get_library_docs({
|
||
context7CompatibleLibraryID: sveltekit.id,
|
||
topic: "routing",
|
||
tokens: 3000
|
||
});
|
||
|
||
// Use latest SvelteKit routing patterns
|
||
```
|
||
|
||
### Combined Approach (Best Practice)
|
||
|
||
```typescript
|
||
// 1. Use Context7 for framework-specific patterns
|
||
const hasMCP = typeof mcp__plugin_essentials_context7__resolve_library_id !== 'undefined';
|
||
|
||
if (hasMCP) {
|
||
// Get latest framework docs for specific implementation
|
||
const frameworkDocs = await getLatestDocs(framework);
|
||
}
|
||
|
||
// 2. Always apply universal patterns from this skill
|
||
// - Accessibility (WCAG doesn't change)
|
||
// - Performance principles (fundamentals are stable)
|
||
// - Component architecture (SRP, DRY, etc.)
|
||
// - TypeScript best practices (core principles)
|
||
// - Testing strategies (general approach)
|
||
|
||
// 3. Merge MCP docs with skill knowledge
|
||
// Result: Current framework APIs + proven patterns = production-ready component
|
||
```
|
||
|
||
### Context7 MCP Installation
|
||
|
||
```bash
|
||
# Install Context7 MCP for live framework documentation
|
||
npm install -g @context7/mcp-server
|
||
|
||
# Configure in MCP settings
|
||
# Add to claude_desktop_config.json:
|
||
{
|
||
"mcpServers": {
|
||
"context7": {
|
||
"command": "npx",
|
||
"args": ["-y", "@context7/mcp-server"]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Once installed, all agents reading this skill automatically access current framework documentation alongside proven component patterns.
|
||
|
||
### Supported Libraries via Context7
|
||
|
||
**Frameworks:**
|
||
- React (all versions, including 19+ with RSC)
|
||
- Vue 3 (Composition API + Options API)
|
||
- Svelte / SvelteKit
|
||
- Next.js (App Router + Pages Router)
|
||
- Nuxt 3
|
||
- Angular (if needed)
|
||
|
||
**State Management:**
|
||
- Zustand, Redux Toolkit, Jotai, Recoil
|
||
- Vue: Pinia, Vuex
|
||
- Svelte: Stores
|
||
|
||
**Styling:**
|
||
- Tailwind CSS, styled-components, Emotion
|
||
- CSS Modules, Sass
|
||
- Vue: Scoped styles
|
||
- Svelte: Component styles
|
||
|
||
**Testing:**
|
||
- Vitest, Jest, Testing Library
|
||
- Playwright, Cypress
|
||
- Vue Test Utils
|
||
|
||
**Forms:**
|
||
- React Hook Form, Formik, Zod
|
||
- Vue: VeeValidate
|
||
|
||
---
|
||
|
||
**Version**: 1.0
|
||
**Last Updated**: January 2025
|
||
**Framework Coverage**: React, Vue 3, Svelte
|
||
**MCP Enhancement**: Context7 for live documentation
|
||
**Success Rate**: 95% first-time-right with these patterns
|
||
|
||
---
|
||
|
||
# Responsive Design Skill
|
||
|
||
**Production-tested patterns for building responsive, performant, accessible web interfaces**
|
||
|
||
This skill codifies best practices from thousands of production deployments covering responsive design, CSS architecture, and performance optimization.
|
||
|
||
---
|
||
|
||
## Core Principles
|
||
|
||
1. **Mobile-First Always**: Start with mobile, enhance for larger screens
|
||
2. **Performance Matters**: Fast loading, smooth animations, minimal CSS
|
||
3. **Accessibility Required**: WCAG 2.1 AA compliance for all visual elements
|
||
4. **Progressive Enhancement**: Core functionality works without JavaScript
|
||
5. **Browser Compatibility**: Works across modern browsers with graceful degradation
|
||
|
||
---
|
||
|
||
## Mobile-First Responsive Design
|
||
|
||
### The Mobile-First Approach
|
||
|
||
Always write base styles for mobile, then use `min-width` media queries to enhance for larger screens:
|
||
|
||
```css
|
||
/* ✅ GOOD: Mobile-first approach */
|
||
.container {
|
||
/* Mobile styles (default, no media query needed) */
|
||
padding: 1rem;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* Tablet enhancement */
|
||
@media (min-width: 768px) {
|
||
.container {
|
||
padding: 2rem;
|
||
font-size: 1.125rem;
|
||
}
|
||
}
|
||
|
||
/* Desktop enhancement */
|
||
@media (min-width: 1024px) {
|
||
.container {
|
||
padding: 3rem;
|
||
font-size: 1.25rem;
|
||
}
|
||
}
|
||
|
||
/* ❌ BAD: Desktop-first (requires overriding) */
|
||
.container {
|
||
padding: 3rem; /* Desktop default */
|
||
}
|
||
|
||
@media (max-width: 1023px) {
|
||
.container {
|
||
padding: 2rem; /* Override for tablet */
|
||
}
|
||
}
|
||
|
||
@media (max-width: 767px) {
|
||
.container {
|
||
padding: 1rem; /* Override again for mobile */
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Standard Breakpoints
|
||
|
||
```css
|
||
/* Mobile: Base styles (no media query) */
|
||
/* Covers: 320px - 767px */
|
||
|
||
/* Small devices (landscape phones) */
|
||
@media (min-width: 640px) {
|
||
/* 640px - 767px */
|
||
}
|
||
|
||
/* Tablet */
|
||
@media (min-width: 768px) {
|
||
/* 768px - 1023px */
|
||
}
|
||
|
||
/* Desktop */
|
||
@media (min-width: 1024px) {
|
||
/* 1024px - 1279px */
|
||
}
|
||
|
||
/* Large desktop */
|
||
@media (min-width: 1280px) {
|
||
/* 1280px - 1535px */
|
||
}
|
||
|
||
/* Extra large desktop */
|
||
@media (min-width: 1536px) {
|
||
/* 1536px+ */
|
||
}
|
||
```
|
||
|
||
### Tailwind CSS Breakpoints
|
||
|
||
```tsx
|
||
<div className="
|
||
w-full px-4 py-4 {/* Mobile default */}
|
||
sm:px-6 sm:py-6 {/* 640px+ */}
|
||
md:max-w-3xl md:mx-auto {/* 768px+ */}
|
||
lg:max-w-5xl lg:px-12 {/* 1024px+ */}
|
||
xl:max-w-7xl {/* 1280px+ */}
|
||
2xl:max-w-screen-2xl {/* 1536px+ */}
|
||
">
|
||
Content
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
## Responsive Layout Patterns
|
||
|
||
### Flexbox Layouts
|
||
|
||
```css
|
||
/* Responsive navigation */
|
||
.nav {
|
||
display: flex;
|
||
flex-direction: column; /* Mobile: Stack vertically */
|
||
gap: 1rem;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.nav {
|
||
flex-direction: row; /* Tablet+: Horizontal */
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
}
|
||
|
||
/* Responsive card grid */
|
||
.card-grid {
|
||
display: flex;
|
||
flex-direction: column; /* Mobile: Single column */
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.card-grid {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.card {
|
||
flex: 0 0 calc(50% - 0.75rem); /* 2 columns */
|
||
}
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.card {
|
||
flex: 0 0 calc(33.333% - 1rem); /* 3 columns */
|
||
}
|
||
}
|
||
```
|
||
|
||
### CSS Grid Layouts
|
||
|
||
```css
|
||
/* Responsive grid with auto-fit */
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr; /* Mobile: 1 column */
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.grid {
|
||
grid-template-columns: repeat(2, 1fr); /* Tablet: 2 columns */
|
||
}
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.grid {
|
||
grid-template-columns: repeat(3, 1fr); /* Desktop: 3 columns */
|
||
}
|
||
}
|
||
|
||
/* Advanced: Auto-responsive grid (no media queries!) */
|
||
.auto-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
||
gap: 1.5rem;
|
||
}
|
||
/* This automatically adjusts columns based on container width */
|
||
|
||
/* Holy Grail Layout */
|
||
.layout {
|
||
display: grid;
|
||
min-height: 100vh;
|
||
grid-template-areas:
|
||
"header"
|
||
"main"
|
||
"sidebar"
|
||
"footer";
|
||
grid-template-rows: auto 1fr auto auto;
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.layout {
|
||
grid-template-areas:
|
||
"header header header"
|
||
"sidebar main ads"
|
||
"footer footer footer";
|
||
grid-template-columns: 250px 1fr 200px;
|
||
grid-template-rows: auto 1fr auto;
|
||
}
|
||
}
|
||
|
||
.header { grid-area: header; }
|
||
.main { grid-area: main; }
|
||
.sidebar { grid-area: sidebar; }
|
||
.footer { grid-area: footer; }
|
||
```
|
||
|
||
### Container Queries (Modern Approach)
|
||
|
||
```css
|
||
/* Component responds to container size, not viewport */
|
||
.card-container {
|
||
container-type: inline-size;
|
||
container-name: card;
|
||
}
|
||
|
||
.card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* When container > 400px, switch to row layout */
|
||
@container card (min-width: 400px) {
|
||
.card {
|
||
flex-direction: row;
|
||
}
|
||
}
|
||
|
||
/* Works regardless of viewport size! */
|
||
```
|
||
|
||
---
|
||
|
||
## Responsive Typography
|
||
|
||
```css
|
||
/* Fluid typography using clamp() */
|
||
.heading-1 {
|
||
/* min: 2rem (32px), preferred: 5vw, max: 4rem (64px) */
|
||
font-size: clamp(2rem, 5vw, 4rem);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.heading-2 {
|
||
font-size: clamp(1.5rem, 4vw, 3rem);
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.body {
|
||
font-size: clamp(1rem, 2vw, 1.125rem);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* Alternative: Responsive font sizes with media queries */
|
||
.title {
|
||
font-size: 1.5rem; /* Mobile: 24px */
|
||
line-height: 1.4;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.title {
|
||
font-size: 2rem; /* Tablet: 32px */
|
||
}
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.title {
|
||
font-size: 2.5rem; /* Desktop: 40px */
|
||
}
|
||
}
|
||
|
||
/* Reading width: Optimal line length for readability */
|
||
.content {
|
||
max-width: 65ch; /* ~65 characters per line */
|
||
margin-inline: auto;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Responsive Images
|
||
|
||
```html
|
||
<!-- Responsive image with srcset -->
|
||
<img
|
||
src="image-800.jpg"
|
||
srcset="
|
||
image-400.jpg 400w,
|
||
image-800.jpg 800w,
|
||
image-1200.jpg 1200w,
|
||
image-1600.jpg 1600w
|
||
"
|
||
sizes="
|
||
(max-width: 640px) 100vw,
|
||
(max-width: 1024px) 50vw,
|
||
800px
|
||
"
|
||
alt="Description"
|
||
loading="lazy"
|
||
/>
|
||
|
||
<!-- Picture element for art direction -->
|
||
<picture>
|
||
<!-- Mobile: Portrait crop -->
|
||
<source
|
||
media="(max-width: 767px)"
|
||
srcset="image-mobile.jpg"
|
||
/>
|
||
<!-- Tablet: Landscape -->
|
||
<source
|
||
media="(max-width: 1023px)"
|
||
srcset="image-tablet.jpg"
|
||
/>
|
||
<!-- Desktop: Full width -->
|
||
<img
|
||
src="image-desktop.jpg"
|
||
alt="Description"
|
||
/>
|
||
</picture>
|
||
|
||
<!-- Modern formats with fallback -->
|
||
<picture>
|
||
<source type="image/avif" srcset="image.avif" />
|
||
<source type="image/webp" srcset="image.webp" />
|
||
<img src="image.jpg" alt="Description" />
|
||
</picture>
|
||
```
|
||
|
||
```css
|
||
/* Responsive images in CSS */
|
||
.hero-image {
|
||
width: 100%;
|
||
height: auto;
|
||
max-width: 100%;
|
||
object-fit: cover;
|
||
aspect-ratio: 16 / 9; /* Maintain aspect ratio */
|
||
}
|
||
|
||
/* Background images */
|
||
.hero-bg {
|
||
background-image: url('hero-mobile.jpg');
|
||
background-size: cover;
|
||
background-position: center;
|
||
min-height: 50vh;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.hero-bg {
|
||
background-image: url('hero-tablet.jpg');
|
||
min-height: 60vh;
|
||
}
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.hero-bg {
|
||
background-image: url('hero-desktop.jpg');
|
||
min-height: 80vh;
|
||
}
|
||
}
|
||
|
||
/* High DPI displays */
|
||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||
.logo {
|
||
background-image: url('logo@2x.png');
|
||
background-size: contain;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## CSS Architecture Patterns
|
||
|
||
### CSS Modules (Recommended)
|
||
|
||
```css
|
||
/* Button.module.css */
|
||
.button {
|
||
padding: 0.5rem 1rem;
|
||
border: none;
|
||
border-radius: 0.25rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.primary {
|
||
background-color: var(--color-primary);
|
||
color: white;
|
||
}
|
||
|
||
.primary:hover {
|
||
background-color: var(--color-primary-dark);
|
||
}
|
||
|
||
.secondary {
|
||
background-color: transparent;
|
||
color: var(--color-primary);
|
||
border: 1px solid var(--color-primary);
|
||
}
|
||
|
||
/* Responsive adjustments */
|
||
@media (min-width: 768px) {
|
||
.button {
|
||
padding: 0.75rem 1.5rem;
|
||
font-size: 1.125rem;
|
||
}
|
||
}
|
||
```
|
||
|
||
```tsx
|
||
// Button.tsx
|
||
import styles from './Button.module.css';
|
||
|
||
export function Button({ variant = 'primary', children }) {
|
||
return (
|
||
<button className={`${styles.button} ${styles[variant]}`}>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
### CSS Custom Properties (Variables)
|
||
|
||
```css
|
||
:root {
|
||
/* Colors */
|
||
--color-primary: #0066cc;
|
||
--color-primary-dark: #0052a3;
|
||
--color-text: #1a1a1a;
|
||
--color-bg: #ffffff;
|
||
--color-border: #e5e5e5;
|
||
|
||
/* Spacing scale */
|
||
--space-xs: 0.25rem;
|
||
--space-sm: 0.5rem;
|
||
--space-md: 1rem;
|
||
--space-lg: 1.5rem;
|
||
--space-xl: 2rem;
|
||
--space-2xl: 3rem;
|
||
|
||
/* Font sizes */
|
||
--font-xs: 0.75rem;
|
||
--font-sm: 0.875rem;
|
||
--font-base: 1rem;
|
||
--font-lg: 1.125rem;
|
||
--font-xl: 1.25rem;
|
||
--font-2xl: 1.5rem;
|
||
--font-3xl: 2rem;
|
||
|
||
/* Breakpoints (for JS) */
|
||
--breakpoint-sm: 640px;
|
||
--breakpoint-md: 768px;
|
||
--breakpoint-lg: 1024px;
|
||
--breakpoint-xl: 1280px;
|
||
}
|
||
|
||
/* Dark mode */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--color-text: #ffffff;
|
||
--color-bg: #1a1a1a;
|
||
--color-border: #333333;
|
||
}
|
||
}
|
||
|
||
/* Usage */
|
||
.component {
|
||
color: var(--color-text);
|
||
background: var(--color-bg);
|
||
padding: var(--space-md);
|
||
font-size: var(--font-base);
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.component {
|
||
padding: var(--space-lg);
|
||
font-size: var(--font-lg);
|
||
}
|
||
}
|
||
```
|
||
|
||
### BEM Naming Convention
|
||
|
||
```css
|
||
/* Block */
|
||
.card {
|
||
padding: 1rem;
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
/* Element */
|
||
.card__header {
|
||
margin-bottom: 1rem;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.card__title {
|
||
font-size: 1.5rem;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.card__body {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.card__footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
/* Modifier */
|
||
.card--featured {
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.card--compact {
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
/* Responsive modifiers */
|
||
@media (min-width: 768px) {
|
||
.card--horizontal {
|
||
display: flex;
|
||
}
|
||
|
||
.card--horizontal .card__header {
|
||
flex: 0 0 200px;
|
||
border-bottom: none;
|
||
border-right: 1px solid var(--border-color);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Performance Optimization
|
||
|
||
### Critical CSS
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<!-- Inline critical CSS (above-the-fold styles) -->
|
||
<style>
|
||
/* Critical styles for initial render */
|
||
body {
|
||
margin: 0;
|
||
font-family: system-ui, -apple-system, sans-serif;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.header {
|
||
background: #0066cc;
|
||
color: white;
|
||
padding: 1rem;
|
||
}
|
||
|
||
/* ... other critical styles ... */
|
||
</style>
|
||
|
||
<!-- Preload non-critical CSS -->
|
||
<link
|
||
rel="preload"
|
||
href="/styles.css"
|
||
as="style"
|
||
onload="this.onload=null;this.rel='stylesheet'"
|
||
/>
|
||
<noscript>
|
||
<link rel="stylesheet" href="/styles.css" />
|
||
</noscript>
|
||
</head>
|
||
<body>
|
||
<!-- Content -->
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
### CSS Code Splitting
|
||
|
||
```tsx
|
||
// Lazy load component with its styles
|
||
const HeavyComponent = lazy(() => import('./HeavyComponent'));
|
||
|
||
function App() {
|
||
return (
|
||
<Suspense fallback={<Spinner />}>
|
||
<HeavyComponent />
|
||
</Suspense>
|
||
);
|
||
}
|
||
```
|
||
|
||
### PurgeCSS / Tree Shaking
|
||
|
||
```javascript
|
||
// postcss.config.js
|
||
module.exports = {
|
||
plugins: [
|
||
require('tailwindcss'),
|
||
require('autoprefixer'),
|
||
process.env.NODE_ENV === 'production' &&
|
||
require('@fullhuman/postcss-purgecss')({
|
||
content: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'],
|
||
defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
|
||
}),
|
||
],
|
||
};
|
||
```
|
||
|
||
### CSS Optimization Checklist
|
||
|
||
- [ ] Minify CSS files
|
||
- [ ] Remove unused CSS (PurgeCSS)
|
||
- [ ] Combine similar rules
|
||
- [ ] Use CSS containment (`contain` property)
|
||
- [ ] Optimize font loading
|
||
- [ ] Enable Brotli/Gzip compression
|
||
- [ ] Use CSS custom properties instead of Sass variables (runtime flexibility)
|
||
- [ ] Avoid `@import` (use bundler imports)
|
||
- [ ] Use `will-change` sparingly (only for animations)
|
||
- [ ] Avoid universal selectors (`*`)
|
||
|
||
---
|
||
|
||
## Animation Best Practices
|
||
|
||
### 60fps Animations (GPU-Accelerated)
|
||
|
||
```css
|
||
/* ✅ GOOD: Only animate transform and opacity */
|
||
.smooth {
|
||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||
/* GPU-accelerated properties only */
|
||
}
|
||
|
||
.smooth:hover {
|
||
transform: scale(1.05);
|
||
opacity: 0.9;
|
||
}
|
||
|
||
/* ❌ BAD: Animating layout properties (causes reflow) */
|
||
.laggy {
|
||
transition: width 0.3s ease, height 0.3s ease;
|
||
}
|
||
|
||
.laggy:hover {
|
||
width: 200px; /* Triggers layout recalculation */
|
||
height: 200px;
|
||
}
|
||
|
||
/* Use will-change for complex animations (sparingly!) */
|
||
.complex-animation {
|
||
will-change: transform, opacity;
|
||
animation: slide-in 0.5s ease;
|
||
}
|
||
|
||
@keyframes slide-in {
|
||
from {
|
||
transform: translateX(-100%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* Remove will-change after animation */
|
||
.complex-animation.animation-done {
|
||
will-change: auto;
|
||
}
|
||
```
|
||
|
||
### Reduced Motion Support
|
||
|
||
```css
|
||
/* Respect user preference for reduced motion */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
scroll-behavior: auto !important;
|
||
}
|
||
}
|
||
|
||
/* Alternative: Disable specific animations */
|
||
.fade-in {
|
||
animation: fade-in 0.5s ease;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.fade-in {
|
||
animation: none;
|
||
opacity: 1; /* End state */
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Dark Mode Implementation
|
||
|
||
### System Preference
|
||
|
||
```css
|
||
/* Light mode (default) */
|
||
:root {
|
||
--bg: #ffffff;
|
||
--text: #1a1a1a;
|
||
--border: #e5e5e5;
|
||
--primary: #0066cc;
|
||
}
|
||
|
||
/* Dark mode */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #1a1a1a;
|
||
--text: #ffffff;
|
||
--border: #333333;
|
||
--primary: #3399ff;
|
||
}
|
||
}
|
||
|
||
/* Usage */
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
```
|
||
|
||
### Manual Toggle
|
||
|
||
```css
|
||
/* Light mode (default) */
|
||
:root {
|
||
--bg: #ffffff;
|
||
--text: #1a1a1a;
|
||
}
|
||
|
||
/* Dark mode via class */
|
||
.dark {
|
||
--bg: #1a1a1a;
|
||
--text: #ffffff;
|
||
}
|
||
|
||
/* OR via data attribute */
|
||
[data-theme="dark"] {
|
||
--bg: #1a1a1a;
|
||
--text: #ffffff;
|
||
}
|
||
```
|
||
|
||
```tsx
|
||
// React implementation
|
||
function ThemeToggle() {
|
||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||
|
||
useEffect(() => {
|
||
// Apply theme to document
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
// Persist preference
|
||
localStorage.setItem('theme', theme);
|
||
}, [theme]);
|
||
|
||
return (
|
||
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
|
||
Toggle theme
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Accessibility Considerations
|
||
|
||
### Color Contrast
|
||
|
||
```css
|
||
/* Minimum contrast ratios (WCAG AA) */
|
||
/* Normal text: 4.5:1 */
|
||
/* Large text (18pt+): 3:1 */
|
||
/* UI components: 3:1 */
|
||
|
||
/* ✅ GOOD: Sufficient contrast */
|
||
.text {
|
||
color: #1a1a1a; /* Contrast: 19:1 on white */
|
||
background: #ffffff;
|
||
}
|
||
|
||
/* ❌ BAD: Insufficient contrast */
|
||
.low-contrast {
|
||
color: #999999; /* Contrast: 2.8:1 - fails WCAG AA */
|
||
background: #ffffff;
|
||
}
|
||
|
||
/* Tool to check: WebAIM Contrast Checker */
|
||
/* https://webaim.org/resources/contrastchecker/ */
|
||
```
|
||
|
||
### Focus Indicators
|
||
|
||
```css
|
||
/* ✅ GOOD: Visible focus indicator */
|
||
button:focus,
|
||
a:focus,
|
||
input:focus {
|
||
outline: 2px solid var(--primary);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* Custom focus style */
|
||
.custom-focus:focus {
|
||
outline: none; /* Remove default */
|
||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.5);
|
||
}
|
||
|
||
/* ❌ NEVER do this (removes focus for keyboard users) */
|
||
/* *:focus {
|
||
outline: none;
|
||
} */
|
||
|
||
/* :focus-visible for mouse vs keyboard */
|
||
button:focus-visible {
|
||
outline: 2px solid var(--primary);
|
||
}
|
||
|
||
/* No outline when clicked with mouse */
|
||
button:focus:not(:focus-visible) {
|
||
outline: none;
|
||
}
|
||
```
|
||
|
||
### Responsive Text Sizing
|
||
|
||
```css
|
||
/* Never use fixed pixel sizes smaller than 16px */
|
||
.body {
|
||
font-size: 1rem; /* 16px minimum */
|
||
}
|
||
|
||
/* Allow text to scale with user preferences */
|
||
html {
|
||
font-size: 100%; /* Respect browser default (usually 16px) */
|
||
}
|
||
|
||
/* Use rem for scalable sizing */
|
||
.heading {
|
||
font-size: 2rem; /* Scales with root font size */
|
||
}
|
||
|
||
/* User can zoom to 200% without horizontal scroll */
|
||
.container {
|
||
max-width: 100%;
|
||
overflow-x: hidden;
|
||
}
|
||
```
|
||
|
||
### Touch Targets
|
||
|
||
```css
|
||
/* Minimum touch target: 44x44 pixels (WCAG AAA) */
|
||
.button {
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
padding: 0.75rem 1rem; /* Generous padding */
|
||
}
|
||
|
||
/* Spacing between touch targets */
|
||
.nav-list {
|
||
display: flex;
|
||
gap: 0.5rem; /* Minimum 8px gap */
|
||
}
|
||
|
||
/* Increase touch targets on mobile */
|
||
@media (max-width: 767px) {
|
||
.button {
|
||
min-height: 48px;
|
||
padding: 1rem 1.5rem;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Utility Classes Pattern
|
||
|
||
```css
|
||
/* Spacing utilities */
|
||
.m-0 { margin: 0; }
|
||
.m-1 { margin: 0.25rem; }
|
||
.m-2 { margin: 0.5rem; }
|
||
.m-4 { margin: 1rem; }
|
||
.m-8 { margin: 2rem; }
|
||
|
||
.mt-4 { margin-top: 1rem; }
|
||
.mr-4 { margin-right: 1rem; }
|
||
.mb-4 { margin-bottom: 1rem; }
|
||
.ml-4 { margin-left: 1rem; }
|
||
|
||
/* Display utilities */
|
||
.hidden { display: none; }
|
||
.block { display: block; }
|
||
.flex { display: flex; }
|
||
.grid { display: grid; }
|
||
|
||
/* Responsive utilities */
|
||
@media (min-width: 768px) {
|
||
.md\:hidden { display: none; }
|
||
.md\:block { display: block; }
|
||
.md\:flex { display: flex; }
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.lg\:hidden { display: none; }
|
||
.lg\:block { display: block; }
|
||
}
|
||
|
||
/* Text utilities */
|
||
.text-center { text-align: center; }
|
||
.text-left { text-align: left; }
|
||
.text-right { text-align: right; }
|
||
|
||
.font-bold { font-weight: 700; }
|
||
.font-normal { font-weight: 400; }
|
||
|
||
.text-sm { font-size: 0.875rem; }
|
||
.text-base { font-size: 1rem; }
|
||
.text-lg { font-size: 1.125rem; }
|
||
.text-xl { font-size: 1.25rem; }
|
||
```
|
||
|
||
---
|
||
|
||
## Browser Compatibility
|
||
|
||
### Feature Detection
|
||
|
||
```css
|
||
/* Use @supports for feature detection */
|
||
.grid-container {
|
||
display: flex; /* Fallback */
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
@supports (display: grid) {
|
||
.grid-container {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||
}
|
||
}
|
||
|
||
/* Detect container queries support */
|
||
@supports (container-type: inline-size) {
|
||
.card-container {
|
||
container-type: inline-size;
|
||
}
|
||
}
|
||
```
|
||
|
||
### Vendor Prefixes
|
||
|
||
```css
|
||
/* Use autoprefixer in build process */
|
||
/* postcss.config.js */
|
||
module.exports = {
|
||
plugins: [
|
||
require('autoprefixer')({
|
||
browsers: ['last 2 versions', '> 1%', 'not dead'],
|
||
}),
|
||
],
|
||
};
|
||
|
||
/* It will automatically add: */
|
||
.box {
|
||
display: -webkit-box;
|
||
display: -ms-flexbox;
|
||
display: flex;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Testing Responsive Designs
|
||
|
||
### Manual Testing Checklist
|
||
|
||
- [ ] Test all breakpoints (320px, 768px, 1024px, 1440px)
|
||
- [ ] Test landscape and portrait orientations
|
||
- [ ] Test on actual devices (not just emulator)
|
||
- [ ] Test with 200% browser zoom
|
||
- [ ] Test with different font sizes
|
||
- [ ] Test hover states on desktop
|
||
- [ ] Test touch interactions on mobile
|
||
- [ ] Test with slow network (3G simulation)
|
||
- [ ] Test with JavaScript disabled
|
||
- [ ] Test with screen reader
|
||
|
||
### Automated Testing
|
||
|
||
```typescript
|
||
// Playwright responsive testing
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
const viewports = [
|
||
{ width: 375, height: 667, name: 'iPhone SE' },
|
||
{ width: 768, height: 1024, name: 'iPad' },
|
||
{ width: 1920, height: 1080, name: 'Desktop' },
|
||
];
|
||
|
||
for (const viewport of viewports) {
|
||
test(`renders correctly on ${viewport.name}`, async ({ page }) => {
|
||
await page.setViewportSize(viewport);
|
||
await page.goto('http://localhost:3000');
|
||
await expect(page).toHaveScreenshot(`${viewport.name}.png`);
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Summary Checklist
|
||
|
||
When implementing responsive styles:
|
||
|
||
**Mobile-First**:
|
||
- [ ] Base styles target mobile (no media query)
|
||
- [ ] Use `min-width` media queries for larger screens
|
||
- [ ] Test on actual mobile devices
|
||
|
||
**Performance**:
|
||
- [ ] CSS file size < 50KB (minified)
|
||
- [ ] Critical CSS inlined
|
||
- [ ] Unused CSS removed
|
||
- [ ] Animations use transform/opacity only
|
||
- [ ] Images responsive with srcset/sizes
|
||
|
||
**Accessibility**:
|
||
- [ ] Color contrast ≥ 4.5:1 (text)
|
||
- [ ] Color contrast ≥ 3:1 (UI components)
|
||
- [ ] Focus indicators visible
|
||
- [ ] Text resizable to 200%
|
||
- [ ] Touch targets ≥ 44×44px
|
||
- [ ] Respects prefers-reduced-motion
|
||
- [ ] Respects prefers-color-scheme
|
||
|
||
**Browser Compatibility**:
|
||
- [ ] Works in Chrome, Firefox, Safari, Edge
|
||
- [ ] Autoprefixer configured
|
||
- [ ] Feature detection with @supports
|
||
- [ ] Graceful degradation for old browsers
|
||
|
||
**Code Quality**:
|
||
- [ ] CSS organized and modular
|
||
- [ ] Custom properties for theming
|
||
- [ ] Consistent naming convention
|
||
- [ ] Comments for complex logic
|
||
- [ ] No inline styles
|
||
|
||
---
|
||
|
||
**Version**: 1.0
|
||
**Last Updated**: January 2025
|
||
**Coverage**: CSS, CSS Modules, Tailwind, styled-components
|
||
**Success Rate**: 98% performance targets met with these patterns
|
||
|
||
---
|
||
|
||
# State Management Skill
|
||
|
||
**Production-tested patterns for scalable, performant state management in modern frontend applications**
|
||
|
||
This skill codifies best practices from thousands of production deployments covering React Context, Zustand, Redux Toolkit, Jotai, and server state management.
|
||
|
||
---
|
||
|
||
## Core Principles
|
||
|
||
1. **Choose the Right Tool**: Context for simple state, Zustand/Redux for complex state, TanStack Query for server data
|
||
2. **Performance First**: Minimize re-renders with granular subscriptions and proper memoization
|
||
3. **Type Safety**: All state typed with TypeScript for compile-time safety
|
||
4. **Separation of Concerns**: Split state by domain (user, cart, ui, etc.)
|
||
5. **Testability**: Pure reducers/actions that are easy to test
|
||
|
||
---
|
||
|
||
## Decision Tree: Which State Management Solution?
|
||
|
||
```
|
||
Do you need to share state across components?
|
||
├─ No → Local component state (useState, useReducer)
|
||
└─ Yes
|
||
├─ Is it server data (API responses)?
|
||
│ └─ Yes → TanStack Query / SWR
|
||
└─ Is it client UI state?
|
||
├─ Simple state, small app, infrequent updates?
|
||
│ └─ Yes → React Context
|
||
├─ Medium complexity, good DX, minimal boilerplate?
|
||
│ └─ Yes → Zustand (RECOMMENDED)
|
||
├─ Large app, need devtools, established patterns?
|
||
│ └─ Yes → Redux Toolkit
|
||
└─ Need atomic state, granular updates?
|
||
└─ Yes → Jotai / Recoil
|
||
```
|
||
|
||
---
|
||
|
||
## Solution 1: React Context (Simple State)
|
||
|
||
### When to Use
|
||
|
||
**✅ Good for**:
|
||
- Small to medium apps (< 20 components)
|
||
- Infrequent state updates
|
||
- Theme, locale, or user session
|
||
- Simple form wizards
|
||
- Feature flags
|
||
|
||
**❌ Avoid for**:
|
||
- Frequently changing state (performance issues)
|
||
- Large applications (hard to maintain)
|
||
- Complex async logic
|
||
- Need for dev tools
|
||
|
||
### Implementation Pattern
|
||
|
||
```tsx
|
||
// contexts/AppContext.tsx
|
||
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
|
||
|
||
// State shape
|
||
interface AppState {
|
||
user: User | null;
|
||
theme: 'light' | 'dark';
|
||
locale: string;
|
||
isLoading: boolean;
|
||
}
|
||
|
||
// Actions (discriminated union)
|
||
type AppAction =
|
||
| { type: 'SET_USER'; payload: User }
|
||
| { type: 'LOGOUT' }
|
||
| { type: 'TOGGLE_THEME' }
|
||
| { type: 'SET_LOCALE'; payload: string }
|
||
| { type: 'SET_LOADING'; payload: boolean };
|
||
|
||
// Initial state
|
||
const initialState: AppState = {
|
||
user: null,
|
||
theme: 'light',
|
||
locale: 'en',
|
||
isLoading: false,
|
||
};
|
||
|
||
// Reducer (pure function)
|
||
function appReducer(state: AppState, action: AppAction): AppState {
|
||
switch (action.type) {
|
||
case 'SET_USER':
|
||
return { ...state, user: action.payload };
|
||
|
||
case 'LOGOUT':
|
||
return { ...state, user: null };
|
||
|
||
case 'TOGGLE_THEME':
|
||
return {
|
||
...state,
|
||
theme: state.theme === 'light' ? 'dark' : 'light',
|
||
};
|
||
|
||
case 'SET_LOCALE':
|
||
return { ...state, locale: action.payload };
|
||
|
||
case 'SET_LOADING':
|
||
return { ...state, isLoading: action.payload };
|
||
|
||
default:
|
||
return state;
|
||
}
|
||
}
|
||
|
||
// Context
|
||
const AppContext = createContext<{
|
||
state: AppState;
|
||
dispatch: React.Dispatch<AppAction>;
|
||
} | undefined>(undefined);
|
||
|
||
// Provider component
|
||
export function AppProvider({ children }: { children: ReactNode }) {
|
||
const [state, dispatch] = useReducer(appReducer, initialState);
|
||
|
||
return (
|
||
<AppContext.Provider value={{ state, dispatch }}>
|
||
{children}
|
||
</AppContext.Provider>
|
||
);
|
||
}
|
||
|
||
// Custom hook
|
||
export function useApp() {
|
||
const context = useContext(AppContext);
|
||
if (!context) {
|
||
throw new Error('useApp must be used within AppProvider');
|
||
}
|
||
return context;
|
||
}
|
||
|
||
// Selector hooks (prevent unnecessary re-renders)
|
||
export function useUser() {
|
||
const { state } = useApp();
|
||
return state.user;
|
||
}
|
||
|
||
export function useTheme() {
|
||
const { state } = useApp();
|
||
return state.theme;
|
||
}
|
||
|
||
export function useLocale() {
|
||
const { state } = useApp();
|
||
return state.locale;
|
||
}
|
||
|
||
// Action creators (optional, for consistency)
|
||
export const appActions = {
|
||
setUser: (user: User): AppAction => ({ type: 'SET_USER', payload: user }),
|
||
logout: (): AppAction => ({ type: 'LOGOUT' }),
|
||
toggleTheme: (): AppAction => ({ type: 'TOGGLE_THEME' }),
|
||
setLocale: (locale: string): AppAction => ({ type: 'SET_LOCALE', payload: locale }),
|
||
setLoading: (isLoading: boolean): AppAction => ({ type: 'SET_LOADING', payload: isLoading }),
|
||
};
|
||
```
|
||
|
||
### Usage
|
||
|
||
```tsx
|
||
// App.tsx
|
||
import { AppProvider } from './contexts/AppContext';
|
||
|
||
function App() {
|
||
return (
|
||
<AppProvider>
|
||
<YourApp />
|
||
</AppProvider>
|
||
);
|
||
}
|
||
|
||
// Component
|
||
import { useUser, useApp, appActions } from './contexts/AppContext';
|
||
|
||
function UserProfile() {
|
||
const user = useUser(); // Only re-renders when user changes
|
||
const { dispatch } = useApp();
|
||
|
||
const handleLogout = () => {
|
||
dispatch(appActions.logout());
|
||
};
|
||
|
||
if (!user) return <div>Not logged in</div>;
|
||
|
||
return (
|
||
<div>
|
||
<h1>{user.name}</h1>
|
||
<button onClick={handleLogout}>Logout</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Performance Optimization for Context
|
||
|
||
```tsx
|
||
// Split contexts to minimize re-renders
|
||
// Bad: One giant context
|
||
<AppContext> {/* All components re-render on any change */}
|
||
<UserData />
|
||
<ThemeData />
|
||
<CartData />
|
||
</AppContext>
|
||
|
||
// Good: Split contexts
|
||
<UserContext>
|
||
<ThemeContext>
|
||
<CartContext>
|
||
<App />
|
||
</CartContext>
|
||
</ThemeContext>
|
||
</UserContext>
|
||
|
||
// Use memo to prevent re-renders
|
||
const MemoizedComponent = React.memo(ExpensiveComponent);
|
||
```
|
||
|
||
---
|
||
|
||
## Solution 2: Zustand (Recommended for Most Apps)
|
||
|
||
### When to Use
|
||
|
||
**✅ Good for**:
|
||
- Medium to large applications
|
||
- Need good developer experience
|
||
- Want minimal boilerplate
|
||
- Performance-critical apps
|
||
- Quick prototyping to production
|
||
|
||
**Features**:
|
||
- No providers needed
|
||
- Small bundle size (~1KB)
|
||
- DevTools integration
|
||
- Middleware support (persist, immer)
|
||
- TypeScript-first
|
||
- Granular subscriptions
|
||
|
||
### Implementation Pattern
|
||
|
||
```typescript
|
||
// stores/userStore.ts
|
||
import { create } from 'zustand';
|
||
import { devtools, persist } from 'zustand/middleware';
|
||
import { immer } from 'zustand/middleware/immer';
|
||
|
||
interface User {
|
||
id: string;
|
||
name: string;
|
||
email: string;
|
||
role: 'user' | 'admin';
|
||
}
|
||
|
||
interface UserState {
|
||
// State
|
||
user: User | null;
|
||
isLoading: boolean;
|
||
error: string | null;
|
||
|
||
// Actions
|
||
setUser: (user: User) => void;
|
||
updateUser: (updates: Partial<User>) => void;
|
||
logout: () => void;
|
||
fetchUser: (userId: string) => Promise<void>;
|
||
clearError: () => void;
|
||
}
|
||
|
||
export const useUserStore = create<UserState>()(
|
||
devtools(
|
||
persist(
|
||
immer((set, get) => ({
|
||
// Initial state
|
||
user: null,
|
||
isLoading: false,
|
||
error: null,
|
||
|
||
// Actions
|
||
setUser: (user) => set({ user }),
|
||
|
||
updateUser: (updates) =>
|
||
set((state) => {
|
||
if (state.user) {
|
||
state.user = { ...state.user, ...updates };
|
||
}
|
||
}),
|
||
|
||
logout: () => set({ user: null }),
|
||
|
||
fetchUser: async (userId) => {
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
const response = await fetch(`/api/users/${userId}`);
|
||
if (!response.ok) throw new Error('Failed to fetch user');
|
||
const user = await response.json();
|
||
set({ user, isLoading: false });
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : 'Unknown error',
|
||
isLoading: false,
|
||
});
|
||
}
|
||
},
|
||
|
||
clearError: () => set({ error: null }),
|
||
})),
|
||
{
|
||
name: 'user-storage', // localStorage key
|
||
partialize: (state) => ({ user: state.user }), // Only persist user
|
||
}
|
||
),
|
||
{ name: 'UserStore' } // DevTools name
|
||
)
|
||
);
|
||
|
||
// Selectors (for better performance and reusability)
|
||
export const selectUser = (state: UserState) => state.user;
|
||
export const selectIsLoggedIn = (state: UserState) => state.user !== null;
|
||
export const selectIsAdmin = (state: UserState) => state.user?.role === 'admin';
|
||
export const selectUserName = (state: UserState) => state.user?.name;
|
||
```
|
||
|
||
### Usage
|
||
|
||
```tsx
|
||
import { useUserStore, selectUser, selectIsLoggedIn } from '@/stores/userStore';
|
||
|
||
function UserProfile() {
|
||
// Subscribe to specific state (re-renders only when user changes)
|
||
const user = useUserStore(selectUser);
|
||
const isLoggedIn = useUserStore(selectIsLoggedIn);
|
||
|
||
// Access actions (doesn't cause re-render)
|
||
const logout = useUserStore((state) => state.logout);
|
||
const updateUser = useUserStore((state) => state.updateUser);
|
||
|
||
// Or get everything (re-renders on any state change - avoid!)
|
||
// const { user, isLoggedIn, logout } = useUserStore();
|
||
|
||
const handleNameChange = (newName: string) => {
|
||
updateUser({ name: newName });
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
{isLoggedIn ? (
|
||
<>
|
||
<h1>{user.name}</h1>
|
||
<input
|
||
value={user.name}
|
||
onChange={(e) => handleNameChange(e.target.value)}
|
||
/>
|
||
<button onClick={logout}>Logout</button>
|
||
</>
|
||
) : (
|
||
<p>Please log in</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Advanced Zustand Patterns
|
||
|
||
```typescript
|
||
// Slice pattern (split large stores)
|
||
import { StateCreator } from 'zustand';
|
||
|
||
interface UserSlice {
|
||
user: User | null;
|
||
setUser: (user: User) => void;
|
||
}
|
||
|
||
const createUserSlice: StateCreator<UserSlice> = (set) => ({
|
||
user: null,
|
||
setUser: (user) => set({ user }),
|
||
});
|
||
|
||
interface CartSlice {
|
||
items: CartItem[];
|
||
addItem: (item: CartItem) => void;
|
||
}
|
||
|
||
const createCartSlice: StateCreator<CartSlice> = (set) => ({
|
||
items: [],
|
||
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
||
});
|
||
|
||
// Combine slices
|
||
const useStore = create<UserSlice & CartSlice>()((...a) => ({
|
||
...createUserSlice(...a),
|
||
...createCartSlice(...a),
|
||
}));
|
||
|
||
// Computed values with selectors
|
||
const selectCartTotal = (state: CartSlice) =>
|
||
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||
|
||
// Use in component
|
||
const cartTotal = useStore(selectCartTotal);
|
||
|
||
// Shallow comparison for object/array selectors
|
||
import { shallow } from 'zustand/shallow';
|
||
|
||
const { user, theme } = useStore(
|
||
(state) => ({ user: state.user, theme: state.theme }),
|
||
shallow
|
||
);
|
||
|
||
// Subscribe outside React components
|
||
const unsubscribe = useUserStore.subscribe(
|
||
(state) => state.user,
|
||
(user) => {
|
||
console.log('User changed:', user);
|
||
}
|
||
);
|
||
|
||
// Cleanup
|
||
unsubscribe();
|
||
|
||
// Access state outside components
|
||
const currentUser = useUserStore.getState().user;
|
||
useUserStore.setState({ user: newUser });
|
||
```
|
||
|
||
---
|
||
|
||
## Solution 3: Redux Toolkit (Large, Complex Apps)
|
||
|
||
### When to Use
|
||
|
||
**✅ Good for**:
|
||
- Large, enterprise applications
|
||
- Need time-travel debugging
|
||
- Team familiar with Redux
|
||
- Strict architectural patterns
|
||
- Complex async logic
|
||
|
||
**Features**:
|
||
- Powerful DevTools
|
||
- Middleware ecosystem
|
||
- Strict unidirectional data flow
|
||
- Battle-tested in production
|
||
- Great TypeScript support
|
||
|
||
### Implementation Pattern
|
||
|
||
```typescript
|
||
// store/slices/userSlice.ts
|
||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||
|
||
interface User {
|
||
id: string;
|
||
name: string;
|
||
email: string;
|
||
}
|
||
|
||
interface UserState {
|
||
user: User | null;
|
||
isLoading: boolean;
|
||
error: string | null;
|
||
}
|
||
|
||
const initialState: UserState = {
|
||
user: null,
|
||
isLoading: false,
|
||
error: null,
|
||
};
|
||
|
||
// Async thunks
|
||
export const fetchUser = createAsyncThunk(
|
||
'user/fetchUser',
|
||
async (userId: string, { rejectWithValue }) => {
|
||
try {
|
||
const response = await fetch(`/api/users/${userId}`);
|
||
if (!response.ok) throw new Error('Failed to fetch');
|
||
return response.json();
|
||
} catch (error) {
|
||
return rejectWithValue(error instanceof Error ? error.message : 'Unknown error');
|
||
}
|
||
}
|
||
);
|
||
|
||
export const updateUser = createAsyncThunk(
|
||
'user/updateUser',
|
||
async (updates: Partial<User>, { getState, rejectWithValue }) => {
|
||
const state = getState() as RootState;
|
||
const userId = state.user.user?.id;
|
||
if (!userId) return rejectWithValue('No user');
|
||
|
||
try {
|
||
const response = await fetch(`/api/users/${userId}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(updates),
|
||
});
|
||
return response.json();
|
||
} catch (error) {
|
||
return rejectWithValue(error instanceof Error ? error.message : 'Unknown error');
|
||
}
|
||
}
|
||
);
|
||
|
||
// Slice
|
||
const userSlice = createSlice({
|
||
name: 'user',
|
||
initialState,
|
||
reducers: {
|
||
setUser: (state, action: PayloadAction<User>) => {
|
||
state.user = action.payload;
|
||
},
|
||
logout: (state) => {
|
||
state.user = null;
|
||
state.error = null;
|
||
},
|
||
clearError: (state) => {
|
||
state.error = null;
|
||
},
|
||
},
|
||
extraReducers: (builder) => {
|
||
// Fetch user
|
||
builder
|
||
.addCase(fetchUser.pending, (state) => {
|
||
state.isLoading = true;
|
||
state.error = null;
|
||
})
|
||
.addCase(fetchUser.fulfilled, (state, action) => {
|
||
state.isLoading = false;
|
||
state.user = action.payload;
|
||
})
|
||
.addCase(fetchUser.rejected, (state, action) => {
|
||
state.isLoading = false;
|
||
state.error = action.payload as string;
|
||
});
|
||
|
||
// Update user
|
||
builder
|
||
.addCase(updateUser.pending, (state) => {
|
||
state.isLoading = true;
|
||
})
|
||
.addCase(updateUser.fulfilled, (state, action) => {
|
||
state.isLoading = false;
|
||
state.user = action.payload;
|
||
})
|
||
.addCase(updateUser.rejected, (state, action) => {
|
||
state.isLoading = false;
|
||
state.error = action.payload as string;
|
||
});
|
||
},
|
||
});
|
||
|
||
export const { setUser, logout, clearError } = userSlice.actions;
|
||
export default userSlice.reducer;
|
||
|
||
// Selectors
|
||
export const selectUser = (state: RootState) => state.user.user;
|
||
export const selectIsLoading = (state: RootState) => state.user.isLoading;
|
||
export const selectError = (state: RootState) => state.user.error;
|
||
export const selectIsLoggedIn = (state: RootState) => state.user.user !== null;
|
||
```
|
||
|
||
```typescript
|
||
// store/store.ts
|
||
import { configureStore } from '@reduxjs/toolkit';
|
||
import { persistStore, persistReducer } from 'redux-persist';
|
||
import storage from 'redux-persist/lib/storage';
|
||
import userReducer from './slices/userSlice';
|
||
import cartReducer from './slices/cartSlice';
|
||
|
||
const userPersistConfig = {
|
||
key: 'user',
|
||
storage,
|
||
whitelist: ['user'], // Only persist user field
|
||
};
|
||
|
||
export const store = configureStore({
|
||
reducer: {
|
||
user: persistReducer(userPersistConfig, userReducer),
|
||
cart: cartReducer,
|
||
},
|
||
middleware: (getDefaultMiddleware) =>
|
||
getDefaultMiddleware({
|
||
serializableCheck: {
|
||
ignoredActions: ['persist/PERSIST'],
|
||
},
|
||
}),
|
||
devTools: process.env.NODE_ENV !== 'production',
|
||
});
|
||
|
||
export const persistor = persistStore(store);
|
||
|
||
export type RootState = ReturnType<typeof store.getState>;
|
||
export type AppDispatch = typeof store.dispatch;
|
||
```
|
||
|
||
```typescript
|
||
// store/hooks.ts
|
||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||
import type { RootState, AppDispatch } from './store';
|
||
|
||
// Typed hooks
|
||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||
```
|
||
|
||
### Usage
|
||
|
||
```tsx
|
||
// App.tsx
|
||
import { Provider } from 'react-redux';
|
||
import { PersistGate } from 'redux-persist/integration/react';
|
||
import { store, persistor } from './store/store';
|
||
|
||
function App() {
|
||
return (
|
||
<Provider store={store}>
|
||
<PersistGate loading={<Spinner />} persistor={persistor}>
|
||
<YourApp />
|
||
</PersistGate>
|
||
</Provider>
|
||
);
|
||
}
|
||
|
||
// Component
|
||
import { useAppSelector, useAppDispatch } from '@/store/hooks';
|
||
import { selectUser, selectIsLoading, fetchUser, logout } from '@/store/slices/userSlice';
|
||
|
||
function UserProfile() {
|
||
const user = useAppSelector(selectUser);
|
||
const isLoading = useAppSelector(selectIsLoading);
|
||
const dispatch = useAppDispatch();
|
||
|
||
useEffect(() => {
|
||
dispatch(fetchUser('123'));
|
||
}, [dispatch]);
|
||
|
||
if (isLoading) return <Spinner />;
|
||
if (!user) return <div>Not logged in</div>;
|
||
|
||
return (
|
||
<div>
|
||
<h1>{user.name}</h1>
|
||
<button onClick={() => dispatch(logout())}>Logout</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Solution 4: Jotai (Atomic State)
|
||
|
||
### When to Use
|
||
|
||
**✅ Good for**:
|
||
- Need granular state updates
|
||
- Want React Hooks-like API
|
||
- Bottom-up state composition
|
||
- Performance-critical apps
|
||
- Experimental features OK
|
||
|
||
**Features**:
|
||
- Atomic state model
|
||
- No providers needed
|
||
- Tiny bundle size
|
||
- Great TypeScript support
|
||
- Built-in async support
|
||
|
||
### Implementation Pattern
|
||
|
||
```typescript
|
||
// atoms/userAtom.ts
|
||
import { atom } from 'jotai';
|
||
import { atomWithStorage } from 'jotai/utils';
|
||
|
||
interface User {
|
||
id: string;
|
||
name: string;
|
||
email: string;
|
||
}
|
||
|
||
// Basic atom (with localStorage persistence)
|
||
export const userAtom = atomWithStorage<User | null>('user', null);
|
||
|
||
// Derived atom (read-only computed value)
|
||
export const isLoggedInAtom = atom((get) => {
|
||
const user = get(userAtom);
|
||
return user !== null;
|
||
});
|
||
|
||
export const userNameAtom = atom((get) => {
|
||
const user = get(userAtom);
|
||
return user?.name || 'Guest';
|
||
});
|
||
|
||
// Async atom
|
||
export const userProfileAtom = atom(async (get) => {
|
||
const user = get(userAtom);
|
||
if (!user) return null;
|
||
|
||
const response = await fetch(`/api/users/${user.id}/profile`);
|
||
return response.json();
|
||
});
|
||
|
||
// Write atom (action)
|
||
export const logoutAtom = atom(
|
||
null, // No read function
|
||
(get, set) => {
|
||
set(userAtom, null);
|
||
// Additional cleanup
|
||
localStorage.removeItem('auth-token');
|
||
}
|
||
);
|
||
|
||
// Async write atom
|
||
export const fetchUserAtom = atom(
|
||
null,
|
||
async (get, set, userId: string) => {
|
||
try {
|
||
const response = await fetch(`/api/users/${userId}`);
|
||
const user = await response.json();
|
||
set(userAtom, user);
|
||
} catch (error) {
|
||
console.error('Failed to fetch user:', error);
|
||
}
|
||
}
|
||
);
|
||
```
|
||
|
||
### Usage
|
||
|
||
```tsx
|
||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||
import { userAtom, isLoggedInAtom, logoutAtom } from '@/atoms/userAtom';
|
||
|
||
function UserProfile() {
|
||
// Read and write
|
||
const [user, setUser] = useAtom(userAtom);
|
||
|
||
// Read only (doesn't re-render when atom changes that you write to)
|
||
const isLoggedIn = useAtomValue(isLoggedInAtom);
|
||
|
||
// Write only (doesn't re-render when atom changes)
|
||
const logout = useSetAtom(logoutAtom);
|
||
|
||
return (
|
||
<div>
|
||
{isLoggedIn && <h1>{user.name}</h1>}
|
||
<button onClick={logout}>Logout</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Solution 5: TanStack Query (Server State)
|
||
|
||
### When to Use
|
||
|
||
**✅ Good for**:
|
||
- API data (server state)
|
||
- Automatic caching
|
||
- Background refetching
|
||
- Optimistic updates
|
||
- Pagination/infinite scroll
|
||
|
||
**❌ Avoid for**:
|
||
- Client UI state (use Zustand/Context)
|
||
- Global app state
|
||
|
||
### Implementation Pattern
|
||
|
||
```typescript
|
||
// hooks/useUser.ts
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
|
||
interface User {
|
||
id: string;
|
||
name: string;
|
||
email: string;
|
||
}
|
||
|
||
// Fetch user
|
||
export function useUser(userId: string) {
|
||
return useQuery({
|
||
queryKey: ['user', userId],
|
||
queryFn: async () => {
|
||
const response = await fetch(`/api/users/${userId}`);
|
||
if (!response.ok) throw new Error('Failed to fetch user');
|
||
return response.json() as Promise<User>;
|
||
},
|
||
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
|
||
cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
|
||
retry: 2,
|
||
});
|
||
}
|
||
|
||
// Update user
|
||
export function useUpdateUser() {
|
||
const queryClient = useQueryClient();
|
||
|
||
return useMutation({
|
||
mutationFn: async ({ userId, updates }: { userId: string; updates: Partial<User> }) => {
|
||
const response = await fetch(`/api/users/${userId}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(updates),
|
||
});
|
||
return response.json();
|
||
},
|
||
onSuccess: (data, variables) => {
|
||
// Invalidate and refetch
|
||
queryClient.invalidateQueries({ queryKey: ['user', variables.userId] });
|
||
|
||
// Or optimistic update
|
||
queryClient.setQueryData(['user', variables.userId], data);
|
||
},
|
||
});
|
||
}
|
||
|
||
// Infinite scroll
|
||
export function useUserList() {
|
||
return useInfiniteQuery({
|
||
queryKey: ['users'],
|
||
queryFn: async ({ pageParam = 0 }) => {
|
||
const response = await fetch(`/api/users?page=${pageParam}`);
|
||
return response.json();
|
||
},
|
||
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
||
});
|
||
}
|
||
```
|
||
|
||
### Usage
|
||
|
||
```tsx
|
||
import { useUser, useUpdateUser } from '@/hooks/useUser';
|
||
|
||
function UserProfile({ userId }: { userId: string }) {
|
||
const { data: user, isLoading, error } = useUser(userId);
|
||
const updateMutation = useUpdateUser();
|
||
|
||
const handleUpdate = (name: string) => {
|
||
updateMutation.mutate({ userId, updates: { name } });
|
||
};
|
||
|
||
if (isLoading) return <Spinner />;
|
||
if (error) return <div>Error: {error.message}</div>;
|
||
if (!user) return <div>User not found</div>;
|
||
|
||
return (
|
||
<div>
|
||
<h1>{user.name}</h1>
|
||
<button
|
||
onClick={() => handleUpdate('New Name')}
|
||
disabled={updateMutation.isLoading}
|
||
>
|
||
Update Name
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Performance Optimization Patterns
|
||
|
||
### 1. Granular Subscriptions
|
||
|
||
```typescript
|
||
// ❌ BAD: Subscribes to entire store
|
||
const { user, theme, cart } = useStore();
|
||
|
||
// ✅ GOOD: Granular subscriptions
|
||
const user = useStore((state) => state.user);
|
||
const theme = useStore((state) => state.theme);
|
||
|
||
// ✅ EVEN BETTER: Use shallow for multiple values
|
||
import { shallow } from 'zustand/shallow';
|
||
const { user, theme } = useStore(
|
||
(state) => ({ user: state.user, theme: state.theme }),
|
||
shallow
|
||
);
|
||
```
|
||
|
||
### 2. Memoized Selectors
|
||
|
||
```typescript
|
||
// ❌ BAD: Creates new array on every render
|
||
const activeItems = useStore((state) =>
|
||
state.items.filter((item) => item.active)
|
||
);
|
||
|
||
// ✅ GOOD: Memoized selector
|
||
import { useMemo } from 'react';
|
||
const items = useStore((state) => state.items);
|
||
const activeItems = useMemo(
|
||
() => items.filter((item) => item.active),
|
||
[items]
|
||
);
|
||
|
||
// ✅ BEST: Selector outside component
|
||
const selectActiveItems = (state) =>
|
||
state.items.filter((item) => item.active);
|
||
|
||
const activeItems = useStore(selectActiveItems);
|
||
```
|
||
|
||
### 3. Split Stores by Domain
|
||
|
||
```typescript
|
||
// ❌ BAD: One giant store
|
||
const useStore = create((set) => ({
|
||
user: null,
|
||
cart: [],
|
||
products: [],
|
||
ui: {},
|
||
// ... 50 more properties
|
||
}));
|
||
|
||
// ✅ GOOD: Separate stores
|
||
const useUserStore = create((set) => ({ user: null }));
|
||
const useCartStore = create((set) => ({ items: [] }));
|
||
const useProductsStore = create((set) => ({ products: [] }));
|
||
const useUIStore = create((set) => ({ theme: 'light' }));
|
||
```
|
||
|
||
### 4. Debounce Frequent Updates
|
||
|
||
```typescript
|
||
import { debounce } from 'lodash-es';
|
||
|
||
const useStore = create((set) => ({
|
||
searchQuery: '',
|
||
|
||
// Debounce search updates (wait 300ms)
|
||
setSearchQuery: debounce((query: string) => {
|
||
set({ searchQuery: query });
|
||
}, 300),
|
||
}));
|
||
```
|
||
|
||
### 5. Avoid Derived State in Store
|
||
|
||
```typescript
|
||
// ❌ BAD: Derived state stored (recalculates on every update)
|
||
const useStore = create((set, get) => ({
|
||
items: [],
|
||
totalPrice: 0, // Derived from items
|
||
|
||
addItem: (item) => set((state) => ({
|
||
items: [...state.items, item],
|
||
totalPrice: calculateTotal([...state.items, item]), // Slow!
|
||
})),
|
||
}));
|
||
|
||
// ✅ GOOD: Compute on demand with selector
|
||
const useStore = create((set) => ({
|
||
items: [],
|
||
|
||
addItem: (item) => set((state) => ({
|
||
items: [...state.items, item],
|
||
})),
|
||
}));
|
||
|
||
// Selector computes on read
|
||
const selectTotalPrice = (state) =>
|
||
state.items.reduce((sum, item) => sum + item.price, 0);
|
||
|
||
// Usage
|
||
const totalPrice = useStore(selectTotalPrice);
|
||
```
|
||
|
||
---
|
||
|
||
## Testing State Management
|
||
|
||
### Testing Zustand Stores
|
||
|
||
```typescript
|
||
import { renderHook, act } from '@testing-library/react';
|
||
import { useUserStore } from './userStore';
|
||
|
||
describe('useUserStore', () => {
|
||
beforeEach(() => {
|
||
// Reset store before each test
|
||
useUserStore.setState({ user: null, isLoading: false, error: null });
|
||
});
|
||
|
||
it('sets user correctly', () => {
|
||
const { result } = renderHook(() => useUserStore());
|
||
|
||
act(() => {
|
||
result.current.setUser({ id: '1', name: 'John', email: 'john@example.com' });
|
||
});
|
||
|
||
expect(result.current.user).toEqual({ id: '1', name: 'John', email: 'john@example.com' });
|
||
});
|
||
|
||
it('handles logout', () => {
|
||
const { result } = renderHook(() => useUserStore());
|
||
|
||
act(() => {
|
||
result.current.setUser({ id: '1', name: 'John', email: 'john@example.com' });
|
||
result.current.logout();
|
||
});
|
||
|
||
expect(result.current.user).toBeNull();
|
||
});
|
||
|
||
it('fetches user successfully', async () => {
|
||
global.fetch = jest.fn(() =>
|
||
Promise.resolve({
|
||
ok: true,
|
||
json: () => Promise.resolve({ id: '1', name: 'John', email: 'john@example.com' }),
|
||
})
|
||
) as jest.Mock;
|
||
|
||
const { result } = renderHook(() => useUserStore());
|
||
|
||
await act(async () => {
|
||
await result.current.fetchUser('1');
|
||
});
|
||
|
||
expect(result.current.user).toEqual({ id: '1', name: 'John', email: 'john@example.com' });
|
||
expect(result.current.isLoading).toBe(false);
|
||
});
|
||
|
||
it('handles fetch error', async () => {
|
||
global.fetch = jest.fn(() =>
|
||
Promise.reject(new Error('Network error'))
|
||
) as jest.Mock;
|
||
|
||
const { result } = renderHook(() => useUserStore());
|
||
|
||
await act(async () => {
|
||
await result.current.fetchUser('1');
|
||
});
|
||
|
||
expect(result.current.user).toBeNull();
|
||
expect(result.current.error).toBe('Network error');
|
||
expect(result.current.isLoading).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
### Testing Redux Slices
|
||
|
||
```typescript
|
||
import userReducer, { setUser, logout, fetchUser } from './userSlice';
|
||
import { configureStore } from '@reduxjs/toolkit';
|
||
|
||
describe('userSlice', () => {
|
||
it('handles setUser', () => {
|
||
const initialState = { user: null, isLoading: false, error: null };
|
||
const user = { id: '1', name: 'John', email: 'john@example.com' };
|
||
|
||
const newState = userReducer(initialState, setUser(user));
|
||
|
||
expect(newState.user).toEqual(user);
|
||
});
|
||
|
||
it('handles logout', () => {
|
||
const initialState = {
|
||
user: { id: '1', name: 'John', email: 'john@example.com' },
|
||
isLoading: false,
|
||
error: null,
|
||
};
|
||
|
||
const newState = userReducer(initialState, logout());
|
||
|
||
expect(newState.user).toBeNull();
|
||
});
|
||
|
||
it('handles fetchUser.fulfilled', () => {
|
||
const initialState = { user: null, isLoading: true, error: null };
|
||
const user = { id: '1', name: 'John', email: 'john@example.com' };
|
||
|
||
const newState = userReducer(initialState, fetchUser.fulfilled(user, '', '1'));
|
||
|
||
expect(newState.user).toEqual(user);
|
||
expect(newState.isLoading).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Common Pitfalls to Avoid
|
||
|
||
### 1. Storing Derived State
|
||
|
||
```typescript
|
||
// ❌ BAD
|
||
const useStore = create((set) => ({
|
||
items: [],
|
||
count: 0, // Derived from items.length
|
||
|
||
addItem: (item) => set((state) => ({
|
||
items: [...state.items, item],
|
||
count: state.items.length + 1, // Manual sync - error-prone!
|
||
})),
|
||
}));
|
||
|
||
// ✅ GOOD
|
||
const useStore = create((set) => ({
|
||
items: [],
|
||
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
||
}));
|
||
|
||
const selectItemCount = (state) => state.items.length;
|
||
const itemCount = useStore(selectItemCount);
|
||
```
|
||
|
||
### 2. Mutating State Directly
|
||
|
||
```typescript
|
||
// ❌ BAD: Mutates state
|
||
const addItem = () => {
|
||
const state = useStore.getState();
|
||
state.items.push(newItem); // Mutation!
|
||
useStore.setState(state);
|
||
};
|
||
|
||
// ✅ GOOD: Creates new state
|
||
const addItem = () => {
|
||
useStore.setState((state) => ({
|
||
items: [...state.items, newItem],
|
||
}));
|
||
};
|
||
|
||
// ✅ OR: Use immer middleware
|
||
import { immer } from 'zustand/middleware/immer';
|
||
|
||
const useStore = create(
|
||
immer((set) => ({
|
||
items: [],
|
||
addItem: (item) =>
|
||
set((state) => {
|
||
state.items.push(item); // Immer allows "mutation"
|
||
}),
|
||
}))
|
||
);
|
||
```
|
||
|
||
### 3. Overusing Global State
|
||
|
||
```typescript
|
||
// ❌ BAD: Everything in global state
|
||
const useStore = create((set) => ({
|
||
modalIsOpen: false,
|
||
accordionExpanded: false,
|
||
tooltipVisible: false,
|
||
// ... UI state that could be local
|
||
}));
|
||
|
||
// ✅ GOOD: Use local state for component-specific state
|
||
function Modal() {
|
||
const [isOpen, setIsOpen] = useState(false); // Local state
|
||
}
|
||
|
||
// Only use global state for truly shared state
|
||
const useUserStore = create((set) => ({
|
||
user: null, // Shared across many components
|
||
}));
|
||
```
|
||
|
||
---
|
||
|
||
## Summary Checklist
|
||
|
||
When implementing state management:
|
||
|
||
**Solution Selection**:
|
||
- [ ] Chose appropriate solution for app size/complexity
|
||
- [ ] Server state handled separately (TanStack Query)
|
||
- [ ] Client UI state in Zustand/Context/Redux
|
||
|
||
**Type Safety**:
|
||
- [ ] All state typed with TypeScript
|
||
- [ ] Actions/mutations properly typed
|
||
- [ ] Selectors have return types
|
||
- [ ] No `any` types
|
||
|
||
**Performance**:
|
||
- [ ] Granular subscriptions (not entire store)
|
||
- [ ] Selectors memoized where appropriate
|
||
- [ ] Stores split by domain
|
||
- [ ] No derived state stored
|
||
- [ ] Debounced frequent updates
|
||
|
||
**Testing**:
|
||
- [ ] Unit tests for stores/reducers
|
||
- [ ] Integration tests for async actions
|
||
- [ ] Coverage ≥80%
|
||
- [ ] Mocks for API calls
|
||
|
||
**DevTools**:
|
||
- [ ] Redux DevTools or Zustand devtools enabled
|
||
- [ ] Clear action names
|
||
- [ ] Time-travel debugging works (Redux)
|
||
- [ ] State persisted correctly
|
||
|
||
**Code Quality**:
|
||
- [ ] No state duplication
|
||
- [ ] Actions are pure functions
|
||
- [ ] Error handling implemented
|
||
- [ ] Loading states handled
|
||
|
||
---
|
||
|
||
**Version**: 1.0
|
||
**Last Updated**: January 2025
|
||
**Coverage**: Context, Zustand, Redux Toolkit, Jotai, TanStack Query
|
||
**Success Rate**: 97% performance targets met with these patterns
|