ReactHooksTypeScriptFrontend

React Hooks Guide: State, Effects, Context, and Reducers

Use React Hooks by responsibility: useState for local state, useEffect for external synchronization, useReducer for complex transitions, and useContext for shared values.

·Updated ·14 min read·Counting...
React Hooks Guide: State, Effects, Context, and Reducers

React Hooks are easier to choose when you classify the problem first. Use useState for simple component state, useReducer for complex state transitions, useEffect for synchronizing with external systems, and useContext for shared values across a component tree. Extract a custom Hook only when the logic is genuinely reusable.

The two basic Hook rules

Hooks must be called at the top level of a React function component or custom Hook:

function SearchPanel({ enabled }: { enabled: boolean }) {
  const [query, setQuery] = useState("")

  if (!enabled) return null

  return <input value={query} onChange={(event) => setQuery(event.target.value)} />
}

Do not call Hooks inside conditions, loops, event handlers, or ordinary utility functions. React relies on stable call order to associate each Hook with its state. Keep eslint-plugin-react-hooks enabled.

useState: simple local state

Use useState for inputs, toggles, selected items, counters, and other local UI values:

import { useState } from "react"

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount((current) => current + 1)}>
      Clicked {count} times
    </button>
  )
}

Use functional updates when the new value depends on the previous value. Do not mutate objects or arrays in place:

setProfile((profile) => ({ ...profile, name: "Steven" }))
setTools((tools) => [...tools, "Raycast"])

Values that can be derived from props or other state often do not need their own state. Mutable values that do not affect rendering may belong in useRef.

useEffect: external synchronization only

useEffect is for synchronizing a component with something outside React: browser APIs, subscriptions, timers, network connections, or third-party widgets. It is not a general "run code after render" container.

import { useEffect, useState } from "react"

export function OnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine)

  useEffect(() => {
    const update = () => setOnline(navigator.onLine)

    window.addEventListener("online", update)
    window.addEventListener("offline", update)

    return () => {
      window.removeEventListener("online", update)
      window.removeEventListener("offline", update)
    }
  }, [])

  return <span>{online ? "Online" : "Offline"}</span>
}

The decision flow should be: which external system needs synchronization, how do we start it, and how do we stop it when dependencies change or the component unmounts?

Avoid request races

For client-side fetches triggered by user input, cancel outdated requests:

useEffect(() => {
  const controller = new AbortController()

  async function load() {
    const response = await fetch(`/api/tools?q=${query}`, {
      signal: controller.signal,
    })
    const result = await response.json()
    setTools(result)
  }

  load().catch((error) => {
    if (error.name !== "AbortError") console.error(error)
  })

  return () => controller.abort()
}, [query])

In Next.js, first-screen data usually belongs in Server Components or the framework data layer. Client Effects are better for browser-only APIs, interaction-driven requests, and live subscriptions. See Next.js Server vs Client Components.

useReducer: complex state transitions

Use a reducer when state has multiple related fields, many actions, or a workflow that benefits from named transitions:

import { useReducer } from "react"

type State = { status: "idle" | "saving" | "saved"; error?: string }
type Action =
  | { type: "save_started" }
  | { type: "save_succeeded" }
  | { type: "save_failed"; message: string }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "save_started":
      return { status: "saving" }
    case "save_succeeded":
      return { status: "saved" }
    case "save_failed":
      return { status: "idle", error: action.message }
    default:
      return state
  }
}

Reducers must stay pure. Do not fetch data, write localStorage, or mutate the existing state inside the reducer.

useContext: shared values, not a full state manager

Context solves prop drilling for values such as theme, language, current user, or dependency injection:

import { createContext, useContext, useState } from "react"

type Theme = "light" | "dark"
const ThemeContext = createContext<Theme | null>(null)

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme] = useState<Theme>("dark")
  return <ThemeContext value={theme}>{children}</ThemeContext>
}

export function useTheme() {
  const value = useContext(ThemeContext)
  if (value === null) throw new Error("useTheme must be used inside ThemeProvider")
  return value
}

When a Provider value changes, consumers re-render. Avoid putting one large, frequently changing object into a single Context.

Custom Hooks

Custom Hooks reuse stateful logic, not state instances:

import { useEffect, useState } from "react"

export function useDebouncedValue<T>(value: T, delay = 300) {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const timer = window.setTimeout(() => setDebounced(value), delay)
    return () => window.clearTimeout(timer)
  }, [value, delay])

  return debounced
}

Good custom Hooks express a concrete capability, such as useOnlineStatus or useDebouncedValue. Do not wrap code just to make a component shorter.

Subscribe to FreeMac

Weekly picks: free Mac software reviews, trusted source updates, alternatives, and low-friction guides.