React – Hooks
Hooks are how React components manage state, side effects, and shared logic. This page covers every hook used at Cygnus Dynamics — when to use each, common mistakes, and the patterns that keep hook code clean and testable.
Rules of Hooks
These are enforced automatically by eslint-plugin-react-hooks.
- Call hooks at the top level — never inside loops, conditions, or nested functions
- Call hooks only from React function components or other custom hooks — never from plain JavaScript functions
- Custom hooks must start with
use— this is how React identifies them as hooks
// ❌ Incorrect — hook called inside a condition
const OrderCard = ({ order }: OrderCardProps) => {
if (!order) return null // early return before hook call
const [isExpanded, setIsExpanded] = useState(false) // ← violates Rules of Hooks
}
// ✅ Correct — all hooks called before any early return
const OrderCard = ({ order }: OrderCardProps) => {
const [isExpanded, setIsExpanded] = useState(false) // ← hooks first
if (!order) return null
return <div>{order.reference}</div>
}
useState
Use useState for local UI state — state that belongs to a single component and doesn't need to be shared.
Prefer many small over one large
// ✅ Correct — easy to update independently
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<Order[]>([])
// ❌ Incorrect — updating one field requires spreading the rest
const [state, setState] = useState({ isLoading: false, error: null, data: [] })
setState(prev => ({ ...prev, isLoading: true })) // verbose and error-prone
Functional updates for derived state
When the new state depends on the previous state, always use the functional form to avoid stale closures.
// ✅ Correct — functional update is always based on the latest state
const [count, setCount] = useState(0)
setCount(prev => prev + 1)
// ❌ Incorrect — `count` may be stale inside async callbacks
setCount(count + 1)
// ✅ Correct — toggling with functional update
const [isOpen, setIsOpen] = useState(false)
setIsOpen(prev => !prev)
Lazy initialisation for expensive initial state
// ✅ Correct — function is called only once on mount
const [filters, setFilters] = useState(() => {
const saved = localStorage.getItem('orderFilters')
return saved ? JSON.parse(saved) : defaultFilters
})
// ❌ Incorrect — localStorage.getItem runs on every render
const [filters, setFilters] = useState(
JSON.parse(localStorage.getItem('orderFilters') ?? 'null') ?? defaultFilters
)
useReducer
Use useReducer when state has multiple related fields that update together, or when the next state depends on the previous in complex ways.
// ✅ useReducer for a multi-field form with validation state
interface FormState {
email: string
password: string
isSubmitting: boolean
errors: Record<string, string>
}
type FormAction =
| { type: 'SET_FIELD'; field: 'email' | 'password'; value: string }
| { type: 'SET_ERRORS'; errors: Record<string, string> }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_FAILURE'; error: string }
const formReducer = (state: FormState, action: FormAction): FormState => {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value, errors: {} }
case 'SET_ERRORS':
return { ...state, errors: action.errors }
case 'SUBMIT_START':
return { ...state, isSubmitting: true, errors: {} }
case 'SUBMIT_SUCCESS':
return { ...state, isSubmitting: false }
case 'SUBMIT_FAILURE':
return { ...state, isSubmitting: false, errors: { form: action.error } }
default:
return state
}
}
const LoginForm = () => {
const [state, dispatch] = useReducer(formReducer, {
email: '',
password: '',
isSubmitting: false,
errors: {},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
dispatch({ type: 'SUBMIT_START' })
try {
await authService.login(state.email, state.password)
dispatch({ type: 'SUBMIT_SUCCESS' })
} catch {
dispatch({ type: 'SUBMIT_FAILURE', error: 'Invalid credentials' })
}
}
}
useEffect
useEffect is for synchronising a component with an external system — DOM APIs, subscriptions, timers, and third-party libraries. It is not for data fetching (use TanStack Query) and not for data transformations (use useMemo).
Dependency arrays must be complete and accurate
// ✅ Correct — all values used inside the effect are in the deps array
useEffect(() => {
document.title = `Order #${orderId} — ${status}`
}, [orderId, status]) // both values in deps
// ❌ Incorrect — missing dependency causes stale values
useEffect(() => {
document.title = `Order #${orderId} — ${status}`
}, [orderId]) // status is missing — title won't update when status changes
Cleanup to prevent memory leaks
Every effect that sets up a subscription, timer, or event listener must return a cleanup function.
// ✅ Correct — subscription cleaned up on unmount or when userId changes
useEffect(() => {
const unsubscribe = orderStatusService.subscribe(userId, (status) => {
setOrderStatus(status)
})
return () => unsubscribe() // ← cleanup
}, [userId])
// ✅ Correct — timer cleaned up
useEffect(() => {
const timer = setInterval(() => {
setSecondsRemaining(prev => prev - 1)
}, 1000)
return () => clearInterval(timer) // ← cleanup
}, [])
// ❌ Incorrect — no cleanup — subscription keeps firing after component unmounts
useEffect(() => {
orderStatusService.subscribe(userId, setOrderStatus)
}, [userId])
Avoid useEffect for derived values
If a value can be calculated from existing state or props synchronously, calculate it during render with useMemo — do not derive it in a useEffect.
// ❌ Incorrect — useEffect + setState for something that can be computed
const [total, setTotal] = useState(0)
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price * item.quantity, 0))
}, [items]) // causes an extra render on every items change
// ✅ Correct — compute during render, no extra state, no extra render
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items]
)
Avoid useEffect for event-driven logic
// ❌ Incorrect — useEffect watching state to trigger a side effect
useEffect(() => {
if (isSubmitted) {
submitOrder(formData)
}
}, [isSubmitted, formData])
// ✅ Correct — call the side effect directly in the event handler
const handleSubmit = async () => {
await submitOrder(formData)
setIsSubmitted(true)
}
useRef
Use useRef for two distinct purposes: accessing DOM elements, and storing mutable values that should not trigger re-renders.
// ✅ DOM access — focus management
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
return <input ref={inputRef} type="text" />
// ✅ Mutable value — storing previous value without triggering re-render
const previousOrderId = useRef<string | null>(null)
useEffect(() => {
if (previousOrderId.current !== orderId) {
trackOrderChange(previousOrderId.current, orderId)
previousOrderId.current = orderId
}
}, [orderId])
// ✅ Mutable value — storing interval ID
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const startPolling = () => {
intervalRef.current = setInterval(fetchStatus, 5000)
}
const stopPolling = () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
useContext
Use useContext for state that is read by many components at different levels of the tree — theme, locale, authenticated user. Do not use it for frequently-updated state (causes re-renders across all consumers).
// ✅ Correct — typed context with a custom hook
interface AuthContextValue {
user: User | null
isAuthenticated: boolean
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
// Always export a hook — never export the context directly
export const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used inside AuthProvider')
}
return context
}
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null>(null)
const logout = () => {
authService.logout()
setUser(null)
}
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, logout }}>
{children}
</AuthContext.Provider>
)
}
// Usage
const ProfileMenu = () => {
const { user, logout } = useAuth()
return (
<div>
<span>{user?.name}</span>
<button onClick={logout}>Sign out</button>
</div>
)
}
useMemo and useCallback
Measure before adding — React 19 compiler note
On projects using the React Compiler (React 19+), manual useMemo and useCallback are often unnecessary — the compiler memoises automatically. Before adding either hook, check whether your project has the compiler enabled. If it does, profile first and add manual memoisation only when you can show a measurable performance benefit.
Use useMemo for expensive computations that should not re-run on every render.
Use useCallback for callbacks passed as props to memoised child components.
// ✅ useMemo — expensive computation
const sortedOrders = useMemo(
() => [...orders].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[orders]
)
// ✅ useCallback — stable reference for a memoised child
const handleDelete = useCallback(
(orderId: string) => {
dispatch({ type: 'DELETE_ORDER', orderId })
},
[dispatch]
)
// ❌ useMemo on a cheap operation — adds overhead without benefit
const title = useMemo(() => `Hello, ${user.name}`, [user.name])
// Just do: const title = `Hello, ${user.name}`
// ❌ Stale closure — missing dispatch in deps
const handleDelete = useCallback(
(orderId: string) => {
dispatch({ type: 'DELETE_ORDER', orderId })
},
[] // dispatch missing — stale reference
)
Custom Hooks
Extract stateful logic and side effects into custom hooks when the same logic is used in more than one component, or when a component becomes difficult to read because it mixes UI and logic.
Custom hook rules
- Name must start with
use - Must be a pure function — no JSX, no rendering
- Should return a stable API (typed return object or tuple)
- Should be testable in isolation with
renderHook
// ✅ Custom hook — data fetching with loading and error state
interface UseOrderResult {
order: Order | null
isLoading: boolean
error: Error | null
refetch: () => void
}
const useOrder = (orderId: string): UseOrderResult => {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['order', orderId],
queryFn: () => orderApi.getOrder(orderId),
enabled: !!orderId,
})
return {
order: data ?? null,
isLoading,
error: error as Error | null,
refetch,
}
}
// ✅ Custom hook — form field with validation
interface UseFieldResult {
value: string
error: string | null
onChange: (value: string) => void
onBlur: () => void
reset: () => void
}
const useField = (
initialValue: string,
validate: (value: string) => string | null
): UseFieldResult => {
const [value, setValue] = useState(initialValue)
const [error, setError] = useState<string | null>(null)
const [isTouched, setIsTouched] = useState(false)
const onChange = (newValue: string) => {
setValue(newValue)
if (isTouched) setError(validate(newValue))
}
const onBlur = () => {
setIsTouched(true)
setError(validate(value))
}
const reset = () => {
setValue(initialValue)
setError(null)
setIsTouched(false)
}
return { value, error, onChange, onBlur, reset }
}
// ✅ Custom hook — media query
const useMediaQuery = (query: string): boolean => {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
useEffect(() => {
const media = window.matchMedia(query)
const listener = (e: MediaQueryListEvent) => setMatches(e.matches)
media.addEventListener('change', listener)
return () => media.removeEventListener('change', listener)
}, [query])
return matches
}
// Usage
const isMobile = useMediaQuery('(max-width: 768px)')
Data Fetching — TanStack Query
Do not use useEffect + useState for data fetching. Use TanStack Query (formerly React Query). It handles loading states, caching, background refetching, error states, and deduplication out of the box.
// ✅ Correct — TanStack Query for all server state
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
const OrderList = ({ userId }: { userId: string }) => {
const { data: orders, isLoading, error } = useQuery({
queryKey: ['orders', userId],
queryFn: () => orderApi.getOrders(userId),
staleTime: 5 * 60 * 1000, // data is fresh for 5 minutes
})
if (isLoading) return <OrderListSkeleton />
if (error) return <ErrorMessage error={error} />
return (
<ul>
{orders?.map(order => (
<OrderListItem key={order.id} order={order} />
))}
</ul>
)
}
const CancelOrderButton = ({ orderId }: { orderId: string }) => {
const queryClient = useQueryClient()
const { mutate: cancelOrder, isPending } = useMutation({
mutationFn: () => orderApi.cancelOrder(orderId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] })
},
onError: (error) => {
toast.error(`Failed to cancel order: ${error.message}`)
},
})
return (
<button onClick={() => cancelOrder()} disabled={isPending}>
{isPending ? 'Cancelling...' : 'Cancel order'}
</button>
)
}
// ❌ Incorrect — reinventing TanStack Query with useEffect
const OrderList = ({ userId }: { userId: string }) => {
const [orders, setOrders] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
setIsLoading(true)
orderApi.getOrders(userId)
.then(setOrders)
.catch(setError)
.finally(() => setIsLoading(false))
}, [userId])
// no caching, no deduplication, no background refetch, no stale-while-revalidate
}