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.

src/components/CheckoutForm.tsxtypescript
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 CheckoutForm

useEffect: 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 arrayRunsTypical use case
Omitted entirelyAfter every single renderRare — almost always a bug unless intentionally syncing on every render
[] (empty)Once, after the first mount onlyOne-time setup: initial fetch, subscribing to a global event on mount
[a, b]After mount, then again whenever a or b changes referenceEffects that must re-sync when specific props/state change
src/hooks/useWindowWidth.tstypescript
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
}
src/hooks/useProductDetails.tstypescript
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.

src/components/ProductList.tsxtypescript
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 ProductList

Module 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.