Hooks are not magic — they're plain functions that rely on call order being stable across renders, which is why the Rules of Hooks forbid calling them conditionally or inside loops. React tracks each hook's state in a linked list attached to the component's fiber node; if the Nth hook call in render N+1 doesn't correspond to the same hook call as render N, the state gets attached to the wrong slot.
useState: Primitives and Object Shapes
useState<T> returns a tuple [value, setValue]. For primitives, TypeScript infers T from the initial value with no annotation needed. For objects, always type the shape explicitly (or via an inferred initial value with every field present) — a partial initial object widens T incorrectly and lets you assign invalid shapes later.
import { useState } from 'react'
interface ShippingAddress {
fullName: string
street: string
city: string
postalCode: string
country: string
}
function CheckoutForm() {
// Primitive — type inferred as number, no annotation needed.
const [quantity, setQuantity] = useState(1)
// Object shape — explicit generic guarantees every field exists on every
// update, and catches typos in field names at compile time.
const [address, setAddress] = useState<ShippingAddress>({
fullName: '',
street: '',
city: '',
postalCode: '',
country: ''
})
// Correct object update: spread the previous object, override only the
// changed field. Never mutate address directly (address.city = '...') —
// React compares state by reference, so a mutated object with the same
// reference will NOT trigger a re-render.
function updateField(field: keyof ShippingAddress, value: string) {
setAddress((prev) => ({ ...prev, [field]: value }))
}
return (
<div className="vstack gap-2">
<input
className="form-control"
type="number"
min={1}
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<input
className="form-control"
placeholder="Full name"
value={address.fullName}
onChange={(e) => updateField('fullName', e.target.value)}
/>
<input
className="form-control"
placeholder="City"
value={address.city}
onChange={(e) => updateField('city', e.target.value)}
/>
</div>
)
}
export default CheckoutFormuseEffect: Dependency Arrays and Rigorous Cleanup
useEffect runs after the DOM has committed, and re-runs whenever any value in its dependency array changes reference between renders. An effect that returns a function registers that function as cleanup, which React calls before the effect re-runs and again on unmount — the functional-component analogue of componentWillUnmount.
| Dependency array | Runs | Typical use case |
|---|---|---|
| Omitted entirely | After every single render | Rare — almost always a bug unless intentionally syncing on every render |
| [] (empty) | Once, after the first mount only | One-time setup: initial fetch, subscribing to a global event on mount |
| [a, b] | After mount, then again whenever a or b changes reference | Effects that must re-sync when specific props/state change |
import { useEffect, useState } from 'react'
/**
* useWindowWidth
* Subscribes to window resize events and returns the current width.
* Demonstrates a rigorous cleanup function preventing a listener leak on
* every unmount — the same discipline as componentWillUnmount in Module
* 3 of the Class Components series, expressed as a Hook.
*/
export function useWindowWidth(): number {
const [width, setWidth] = useState<number>(window.innerWidth)
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth)
}
window.addEventListener('resize', handleResize)
// Cleanup function: React calls this before re-running the effect (N/A
// here, since the dependency array is empty) and unconditionally on
// unmount. Omitting it leaks one 'resize' listener per mount.
return () => {
window.removeEventListener('resize', handleResize)
}
}, []) // empty array — subscribe once, on mount only
return width
}import { useEffect, useState } from 'react'
interface Product {
id: string
name: string
price: number
}
interface UseProductDetailsResult {
product: Product | null
isLoading: boolean
error: string | null
}
/**
* useProductDetails
* Re-fetches whenever productId changes. Uses AbortController so a fetch
* for a stale productId (user navigated away quickly) never overwrites
* state with outdated data — the async equivalent of guarding
* componentDidUpdate with a prop comparison.
*/
export function useProductDetails(productId: string): UseProductDetailsResult {
const [product, setProduct] = useState<Product | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
async function fetchProduct() {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/products/${productId}`, {
signal: controller.signal
})
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
const data: Product = await response.json()
setProduct(data)
} catch (err) {
// AbortError fires when cleanup cancels an in-flight request — not a
// real failure, so it's excluded from user-facing error state.
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message)
}
} finally {
setIsLoading(false)
}
}
fetchProduct()
// Cleanup: abort the in-flight request for the OLD productId before the
// effect re-runs for the new one, or on unmount.
return () => controller.abort()
}, [productId]) // re-run whenever productId changes
return { product, isLoading, error }
}useMemo & useCallback: Reference Equality
Every render creates new object, array, and function literals from scratch — even if their contents are identical to the previous render. useMemo caches a computed value and only recomputes it when its dependencies change; useCallback does the same for a function reference. Both exist purely to preserve reference equality across renders, which matters only when something downstream depends on that reference — a child wrapped in React.memo, or another hook's dependency array.
import { useMemo, useCallback, useState, memo } from 'react'
interface Product {
id: string
name: string
price: number
category: string
}
interface ProductRowProps {
product: Product
onSelect: (id: string) => void
}
// React.memo skips re-rendering ProductRow when its props are shallowly
// equal to the previous render's props. This ONLY pays off if onSelect's
// reference is stable — otherwise memo does nothing, since a new function
// reference every render still fails the shallow-equality check.
const ProductRow = memo(function ProductRow({ product, onSelect }: ProductRowProps) {
return (
<tr onClick={() => onSelect(product.id)}>
<td>{product.name}</td>
<td>${product.price.toFixed(2)}</td>
</tr>
)
})
interface ProductListProps {
products: Product[]
categoryFilter: string
}
function ProductList({ products, categoryFilter }: ProductListProps) {
const [selectedId, setSelectedId] = useState<string | null>(null)
// useMemo: filtering + sorting is O(n log n) — worth caching so it only
// re-runs when `products` or `categoryFilter` actually change, not on
// every render caused by, say, `selectedId` changing.
const visibleProducts = useMemo(() => {
return products
.filter((p) => categoryFilter === 'all' || p.category === categoryFilter)
.sort((a, b) => a.price - b.price)
}, [products, categoryFilter])
// useCallback: gives handleSelect a stable reference across renders, so
// ProductRow's React.memo check actually succeeds instead of always
// seeing a "new" onSelect prop.
const handleSelect = useCallback((id: string) => {
setSelectedId(id)
}, []) // no external dependencies — setSelectedId is guaranteed stable by React
return (
<table className="table">
<tbody>
{visibleProducts.map((product) => (
<ProductRow key={product.id} product={product} onSelect={handleSelect} />
))}
</tbody>
</table>
)
}
export default ProductListModule 2 Takeaways
- useState with an object requires spreading the previous state to create a new reference — direct mutation is invisible to React's change detection.
- useEffect's dependency array controls re-run timing: omitted runs every render, [] runs once on mount, [deps] re-runs when any listed value changes reference.
- The cleanup function returned from useEffect runs before every re-run and on unmount — pair every subscription/listener/timer with matching teardown, same discipline as componentWillUnmount.
- AbortController inside useEffect prevents a stale async response from overwriting state after the dependency changes or the component unmounts.
- useMemo and useCallback exist solely to preserve reference equality across renders — they only help when something downstream (React.memo, a dependency array) actually depends on that stable reference.
- Don't memoize by default — the comparison and cache-retention overhead can cost more than the work being avoided for cheap computations.