| name | react |
| description | React patterns: hooks, context, performance optimisation, component composition, state management, common anti-patterns |
React Skill
When to activate
- Designing component architecture for a new feature
- Debugging performance issues (unnecessary re-renders)
- Choosing between local state, context, and external state management
- Writing custom hooks to share logic across components
- Implementing compound components, render props, or other composition patterns
- Migrating class components to function components
When NOT to use
- Next.js App Router features (Server Components, Server Actions) — use the Next.js skill
- React Native — different platform constraints
- Simple static UI with no state — just write HTML
Instructions
State — where to put it
| State type | Where to put it |
|---|
| UI state (open/closed, selected tab) | Local useState in the component |
| Derived data (filtered list, computed total) | useMemo — no state, derive from props/state |
| Shared between sibling components | Lift to common parent |
| Shared across a subtree (theme, auth, locale) | Context |
| Server state (API data) | React Query / SWR |
| Complex client state with many interactions | Zustand / Jotai |
Default to local state. Only lift when multiple components need it.
Custom hooks — the right abstraction
Extract logic into a custom hook when the same stateful logic appears in 2+ components:
function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(timer)
}, [value, delayMs])
return debounced
}
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch { return initialValue }
})
const setStoredValue = (newValue: T) => {
setValue(newValue)
window.localStorage.setItem(key, JSON.stringify(newValue))
}
return [value, setStoredValue] as const
}
useAsync<T>(: <T>, : []) {
[state, setState] = useState<{
: T | ; : | ; :
}>({ : , : , : })
( {
( ({ ...s, : }))
()
.( ({ data, : , : }))
.( ({ : , error, : }))
}, deps)
state
}
Context — only for truly global data
type AuthContextValue = {
user: User | null
signIn: (email: string, password: string) => Promise<void>
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const signIn = async (email: string, password: string) => {
const user = await api.signIn(email, password)
setUser(user)
}
const signOut = async () => {
await api.signOut()
setUser(null)
}
return (
)
}
(): {
ctx = ()
(!ctx) ()
ctx
}
Context anti-patterns to avoid:
- Don't put everything in one giant context — split by concern
- Don't put fast-changing values (mouse position, scroll) in context — causes re-renders for all consumers
- Don't use context as a substitute for proper state management at scale
Performance — fixing unnecessary re-renders
const UserCard = memo(({ user }: { user: User }) => {
return <div>{user.name}</div>
})
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
)
const handleSubmit = useCallback(async (data: FormData) => {
await api.submit(data)
refetch()
}, [refetch])
const HeavyChart = lazy(() => import('./HeavyChart'))
function Dashboard() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
)
}
How to find what's re-rendering:
- React DevTools Profiler → record → click around → see which components render and why
- Add
console.log('render', componentName) temporarily
why-did-you-render library for detailed re-render reasons
When NOT to memoize: memoization has overhead. Only add memo/useMemo/useCallback when you've confirmed a component re-renders too often and it's causing actual slowness.
Component composition patterns
Compound components (related components that share state):
const SelectContext = createContext<SelectContextValue | null>(null)
function Select({ children, value, onChange }: SelectProps) {
return (
<SelectContext.Provider value={{ value, onChange }}>
<div className="select">{children}</div>
</SelectContext.Provider>
)
}
Select.Option = function Option({ value, children }: OptionProps) {
const ctx = useContext(SelectContext)!
return (
<div
className={ctx.value === value ? 'selected' : ''}
onClick={() => ctx.onChange(value)}
>
{children}
</div>
)
}
<Select value={selected} onChange={setSelected}>
< =>Option A
</>
Render props / children as function:
function DataFetcher<T>({ url, children }: {
url: string
children: (data: T | null, loading: boolean, error: Error | null) => ReactNode
}) {
const { data, loading, error } = useAsync(() => fetch(url).then(r => r.json()), [url])
return <>{children(data, loading, error)}</>
}
<DataFetcher url="/api/users">
{(users, loading, error) => {
if (loading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <UserList users={users} />
}}
</DataFetcher>
Common anti-patterns
Derived state as state (the most common mistake):
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')
const fullName = `${firstName} ${lastName}`
useEffect for data that can be computed synchronously:
useEffect(() => {
setFilteredItems(items.filter(i => i.active))
}, [items])
const filteredItems = useMemo(
() => items.filter(i => i.active),
[items]
)
Stale closures in useEffect:
useEffect(() => {
const interval = setInterval(() => {
console.log(count)
}, 1000)
return () => clearInterval(interval)
}, [])
const countRef = useRef(count)
countRef.current = count
useEffect(() => {
const interval = setInterval(() => {
console.log(countRef.current)
}, 1000)
return () => clearInterval(interval)
}, [])
State management with Zustand
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type CartStore = {
items: CartItem[]
addItem: (item: CartItem) => void
removeItem: (id: string) => void
total: () => number
}
export const useCart = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set(state => ({ items: [...state.items, item] })),
removeItem: (id) => set(state => ({
items: state.items.filter(i => i.id !== id)
})),
total: () => get()..( sum + i. * i., ),
}),
{ : }
)
)
Example
User: A product filter component re-renders too often and slows down a list of 500 items.
Expected analysis and fix:
- Wrap the item component in
memo — items re-render only when their own props change
- Move filter state up and wrap the filter function in
useCallback
- Use
useMemo for the filtered + sorted list computation
- Virtualise the list with
@tanstack/virtual if still slow after memoisation