React Patterns
Modern React patterns, hooks best practices, anti-patterns, and quality rules.
Core Principles
| Principle | Description |
|---|---|
| Composition | Small, focused components over inheritance |
| Unidirectional | Data flows down, events flow up |
| Declarative | Describe what, not how |
| Hooks | Functional components with hooks over class components |
Component Patterns
Functional Components
// GOOD: Typed functional component
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
export function Button({
label,
onClick,
variant = 'primary',
disabled = false,
}: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
disabled={disabled}
>
{label}
</button>
);
}
// BAD: Class component (outdated)
class Button extends React.Component { }
Composition Over Props Drilling
// GOOD: Compound components
function Card({ children }: { children: React.ReactNode }) {
return <div className="card">{children}</div>;
}
Card.Header = function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>;
};
Card.Body = function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>;
};
// Usage
<Card>
<Card.Header>Title</Card.Header>
<Card.Body>Content</Card.Body>
</Card>
Render Props / Children as Function
// GOOD: Flexible render pattern
interface DataFetcherProps<T> {
url: string;
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const { data, loading, error } = useFetch<T>(url);
return <>{children(data, loading, error)}</>;
}
// Usage
<DataFetcher<User[]> url="/api/users">
{(users, loading, error) => {
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <UserList users={users!} />;
}}
</DataFetcher>
Hooks Best Practices
useState
// GOOD: Typed state
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);
// GOOD: Functional updates for derived state
setCount(prev => prev + 1);
setItems(prev => [...prev, newItem]);
// BAD: Object mutation
setUser({ ...user, name: 'New' }); // Works but...
user.name = 'New'; // NEVER mutate directly!
setUser(user);
useEffect
// GOOD: Proper dependency array
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch(url, { signal: controller.signal });
setData(await response.json());
} catch (error) {
if (!controller.signal.aborted) {
setError(error as Error);
}
}
}
fetchData();
return () => controller.abort();
}, [url]); // Re-run when url changes
// BAD: Missing dependencies
useEffect(() => {
fetchData(userId); // userId not in deps - stale closure!
}, []);
// BAD: Object/array in deps (always new reference)
useEffect(() => {
doSomething(options);
}, [options]); // Infinite loop if options = {} inline
useMemo / useCallback
// GOOD: Expensive computation
const sortedItems = useMemo(
() => items.sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
// GOOD: Stable callback for child props
const handleClick = useCallback(
(id: string) => {
onSelect(id);
},
[onSelect]
);
// BAD: Premature optimization
const double = useMemo(() => value * 2, [value]); // Too simple
// BAD: No deps for callback
const handleClick = useCallback(() => {
onSelect(selectedId); // selectedId is stale!
}, []);
Custom Hooks
// GOOD: Extract reusable logic
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage('theme', 'light');
Anti-Patterns to Avoid
Index as Key
// BAD: Index key causes render issues
{items.map((item, index) => (
<Item key={index} data={item} /> // Re-renders all on reorder!
))}
// GOOD: Stable unique key
{items.map(item => (
<Item key={item.id} data={item} />
))}
Props in State
// BAD: Copying props to state
function UserProfile({ user }: { user: User }) {
const [userData, setUserData] = useState(user); // Stale!
// ...
}
// GOOD: Derive from props or use effect to sync
function UserProfile({ user }: { user: User }) {
const displayName = user.name.toUpperCase(); // Derive directly
// ...
}
useEffect for Transforms
// BAD: Effect for derived data
const [items, setItems] = useState([]);
const [filtered, setFiltered] = useState([]);
useEffect(() => {
setFiltered(items.filter(i => i.active));
}, [items]);
// GOOD: Compute directly or useMemo
const filtered = useMemo(
() => items.filter(i => i.active),
[items]
);
Excessive Re-renders
// BAD: New object/function on every render
<Child
config={{ theme: 'dark' }} // New object every render!
=> handleClick(id)} // New function every render!
/>
// GOOD: Stable references
const config = useMemo(() => ({ theme: 'dark' }), []);
const handleChildClick = useCallback(() => handleClick(id), [id]);
<Child config={config} />
State Management
Context for Global State
// GOOD: Typed context with provider
interface AuthContextType {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Reducer for Complex State
type Action =
| { type: 'SET_LOADING' }
| { type: 'SET_DATA'; payload: Data }
| { type: 'SET_ERROR'; payload: Error };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_LOADING':
return { ...state, loading: true, error: null };
case 'SET_DATA':
return { ...state, loading: false, data: action.payload };
case 'SET_ERROR':
return { ...state, loading: false, error: action.payload };
}
}
Quality Checklist
| Check | Rule |
|---|---|
| Unique keys | Never use index as key for dynamic lists |
| Hook deps | All dependencies listed, no lint suppressions |
| No props in state | Derive from props directly |
| Stable callbacks | useCallback for event handlers passed to children |
| Error boundaries | Wrap feature sections |
| Typed props | Interface for all component props |
| Controlled inputs | value + onChange, not defaultValue |
| Cleanup effects | Return cleanup function for subscriptions |
| Memoize expensive | useMemo for costly computations only |
| Custom hooks | Extract reusable stateful logic |
Invocation — React Anti-Pattern Checker (L3 Script)
Run the checker on any JSX or TSX file. Consume its output only — the script source never enters context.
Scope note: This is a regex-based structural checker, not a full JSX/AST parser. It reliably catches three closed-set patterns:
key={index}(index used as key in map)- Missing
keyprop in.map(JSX render (JSX element withoutkey=on same or following lines) - Hooks called inside conditional blocks (
if/&&/ternary body)
It does NOT detect prop-drilling depth (requires component tree analysis), missing useEffect dependencies (requires full scope analysis), or render performance issues. Use eslint-plugin-react-hooks for those.
Run via Bash (file argument):
python .claude/skills/code/react-patterns/scripts/react_patterns.py path/to/Component.tsx
Run via Bash (stdin — paste code or pipe):
cat path/to/Component.jsx | python .claude/skills/code/react-patterns/scripts/react_patterns.py -
The script outputs:
- A JSON object with
findings(list of anti-patterns with rule, severity, line, message) and asummaryof counts by severity. - A human-readable markdown table sorted by severity descending.
Detected rules:
INDEX_AS_KEYHIGH —key={index}orkey={i}in map callback (causes incorrect reconciliation on reorder)MISSING_KEYMEDIUM —.map(call with JSX element that has nokey=prop within 5 linesHOOK_IN_CONDITIONALHIGH — Hook call (use...()) inside anifstatement or&&expression (violates Rules of Hooks)
Error handling: Script exits 1 on unreadable file. Exits 0 even if findings are present.
What the agent does with the output:
- HIGH findings (INDEX_AS_KEY, HOOK_IN_CONDITIONAL) must be fixed before merge.
- MISSING_KEY findings may be false positives for static lists — verify that the list is truly static before dismissing.