Master the hooks you use daily — useState and immutable updates, useEffect, useRef, useContext, useReducer, useMemo, useCallback, and custom hooks.
Why: props come from the parent and are read-only; state is owned by the component and changes over time. You write state as const [value, setValue] = useState(initialValue) — useState takes the initial value and returns a pair: the current value and the setter that updates it, conventionally named setX. Calling the setter re-renders the component with the new value — that is the entire React update model.
'use client'
import { useState } from 'react'
// step is a prop (passed in, read-only)
// count is state (owned here, changes over time)
function Counter({ step }: { step: number }) {
// count = the current value; setCount = updates it and re-renders
const [count, setCount] = useState(0)
return (
// Setting state re-renders this component with the new value
<button onClick={() => setCount(count + step)}>Count: {count}</button>
)
}
export default function Page() {
return <Counter step={2} />
}Why: React only updates the screen when it sees a NEW array or object — it checks "is this the same one as before?", not "did the contents change?". If you push into the existing array or change a field on the existing object, React thinks nothing changed and the screen stays stale. So never modify state in place — always build a new array or object, usually by spreading the old one. Note: when the next value depends on the previous, use the updater form setX(prev => …).
'use client'
import { useState } from 'react'
export default function TodoApp() {
// [] doesn't reveal what the items will be, so the type is written out
const [todos, setTodos] = useState<string[]>([])
const [user, setUser] = useState({ name: 'Ada', age: 36 })
function addTodo() {
// Never todos.push(…) — create a NEW array
setTodos([...todos, 'Todo #' + (todos.length + 1)])
}
function birthday() {
// Same for objects: spread, then override
setUser({ ...user, age: user.age + 1 })
}
function addTwo() {
// Updater form — each call sees the latest value
setTodos((prev) => [...prev, 'first'])
setTodos((prev) => [...prev, 'second'])
}
return (
<div>
<button onClick={addTodo}>Add</button>
<button onClick={birthday}>{user.name} is {user.age}</button>
<button onClick={addTwo}>Add two</button>
<p>{todos.join(', ')}</p>
</div>
)
}Why: effects connect your component to things React does not control — timers, subscriptions, browser APIs. You write it as useEffect(() => { … }, [deps]) — a function that runs after the component renders, plus a dependency array. The dependency array controls when it runs again: [] means only once, when the component first appears on screen ("mounts"); [dep] means again whenever dep changes. Note: return a cleanup function to undo the effect; React calls it when the component leaves the screen and before every re-run. Data fetching mostly belongs in Server Components or TanStack Query, not useEffect.
'use client'
import { useEffect, useState } from 'react'
export default function Clock() {
// null until the first tick — that's why the type is Date | null
const [now, setNow] = useState<Date | null>(null)
useEffect(() => {
// Runs after render — set up the outside-world connection
const id = setInterval(() => setNow(new Date()), 1000)
// Cleanup — runs on unmount and before each re-run
return () => clearInterval(id)
}, []) // [] = run once on mount; [dep] = re-run when dep changes
return <p>{now ? now.toLocaleTimeString() : 'Loading…'}</p>
}Why: two jobs. First, direct access to a real element on the page (to focus an input, scroll, or measure it). Second, a box that keeps a value between renders WITHOUT redrawing anything when it changes — unlike state. You write it as const ref = useRef(initialValue) and read or write ref.current; attach it to an element with ref={ref} to get element access. Note: changing ref.current never re-renders, so nothing on screen reacts to it; and never read or write refs during render.
'use client'
import { useRef } from 'react'
export default function SearchBar() {
// 1. DOM access — ref.current is the real <input> element
const inputRef = useRef<HTMLInputElement>(null)
// 2. A value that persists across renders WITHOUT triggering them
const submitCount = useRef(0)
function handleSubmit() {
submitCount.current++
console.log('submitted', submitCount.current, 'times')
}
return (
<div>
<input ref={inputRef} placeholder="Search…" />
<button onClick={() => inputRef.current?.focus()}>Focus input</button>
<button onClick={handleSubmit}>Submit</button>
</div>
)
}Why: context delivers a value to any component below the provider without threading it through every layer of props. Three steps: create, provide, consume — written as const Ctx = createContext(defaultValue), then <Ctx value={…}> around the tree, then const value = useContext(Ctx) in any component below. Note: React 19 lets you render the context itself as the provider — older codebases use <ThemeContext.Provider> instead.
'use client'
import { createContext, useContext, useState } from 'react'
// 1. Create — with a default value
const ThemeContext = createContext<'light' | 'dark'>('light')
// 3. Consume — at any depth, no prop drilling
function Toolbar() {
const theme = useContext(ThemeContext)
return <p>Current theme: {theme}</p>
}
export default function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
return (
// 2. Provide — everything inside can read the value
<ThemeContext value={theme}>
<Toolbar />
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle theme
</button>
</ThemeContext>
)
}Why: when several pieces of state change together, or the next state follows intricate rules, a reducer collects all the update logic in one place instead of scattering it across event handlers — components just announce what happened. You write it as const [state, dispatch] = useReducer(reducer, initialState) — the reducer is a function that takes the current state and an action ("what happened") and returns the next state; calling dispatch(action) runs it and re-renders with the result.
'use client'
import { useReducer } from 'react'
// The values that change together, kept in one object
type State = { count: number; step: number }
// Everything that can happen, listed as actions
type Action =
| { type: 'increment' }
| { type: 'setStep'; step: number }
| { type: 'reset' }
// All the update logic in one place:
// current state + action ("what happened") → next state
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment': return { ...state, count: state.count + state.step }
case 'setStep': return { ...state, step: action.step }
case 'reset': return { count: 0, step: 1 }
}
}
export default function Counter() {
// state is what you render; dispatch announces what happened
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 })
return (
<div>
<p>{state.count}</p>
{/* The buttons don't compute anything — they just dispatch */}
<button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
<button onClick={() => dispatch({ type: 'setStep', step: 5 })}>Step = 5</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
)
}Why: both are about not redoing work on every render. useMemo remembers a computed value, and useCallback remembers a function instead of recreating it each render — both hand back the previous one until something in the dependency array changes. You write them as const value = useMemo(() => compute(), [deps]) and const fn = useCallback(() => { … }, [deps]). When: a slow computation, or a function passed down to a child that should skip needless re-renders. Note: do not sprinkle them everywhere; measure first. The React Compiler is making most of this manual caching unnecessary.
'use client'
import { useCallback, useMemo, useState } from 'react'
export default function ProductList({ products }: { products: string[] }) {
const [query, setQuery] = useState('')
const [cart, setCart] = useState<string[]>([])
// Recomputes only when products or query change — not on every render
const filtered = useMemo(
() => products.filter((p) => p.toLowerCase().includes(query.toLowerCase())),
[products, query],
)
// Same function identity between renders
const addToCart = useCallback((item: string) => {
setCart((prev) => [...prev, item])
}, [])
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{filtered.map((p) => (
<button key={p} onClick={() => addToCart(p)}>{p}</button>
))}
<p>{cart.length} item(s) in cart</p>
</div>
)
}Why: any reusable logic built from other hooks can be extracted into a function whose name starts with "use" — that is the whole feature. You write one as an ordinary function, function useThing(args) { … }, that calls other hooks inside and returns whatever the component needs. Rules of hooks: call hooks only at the top level of components or other hooks, never inside loops, conditions, or callbacks.
'use client'
import { useEffect, useState } from 'react'
// Reusable logic = a function that starts with "use" and calls hooks
// (the <T> just means it works for any type of value)
function useDebounce<T>(value: T, ms = 300): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
// Wait ms, then accept the value — cancelled if it changes sooner
const id = setTimeout(() => setDebounced(value), ms)
return () => clearTimeout(id)
}, [value, ms])
// The component uses what you return like any other variable
return debounced
}
export default function Search() {
const [text, setText] = useState('')
const query = useDebounce(text, 500) // settles 500ms after typing stops
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<p>Searching for: {query}</p>
</div>
)
}