DEV_NET_CORE
GET_STARTED
ReactHooks, effects, and custom hooks

useState, useReducer, useContext, and custom hooks

Overview

React Hooks let functional components use React features such as state, context, effects, memoization, refs, and transitions without writing class components. This topic focuses on four interview-critical areas:

  • useState for local component state.
  • useReducer for structured state transitions.
  • useContext for reading shared values from a provider.
  • Custom hooks for extracting reusable stateful logic.
Code
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((current) => current + 1)}>
      Count: {count}
    </button>
  );
}

Hooks matter because modern React applications are built from small functional components and reusable hooks. Interviews often test whether a developer understands state snapshots, functional updates, reducers, dispatch actions, context boundaries, provider value stability, rules of hooks, and when a custom hook is a useful abstraction instead of just a wrapper.

The practical goal is to put state and shared logic in the right place, keep render logic predictable, and make components easier to test, reuse, and reason about.

Core Concepts

What Hooks Are

Hooks are functions that let React components use React features. Built-in hooks include useState, useReducer, useContext, useEffect, useMemo, useCallback, and useRef.

Hooks are called inside function components or inside custom hooks:

Code
function UserMenu() {
  const [open, setOpen] = useState(false);

  return (
    <button onClick={() => setOpen((value) => !value)}>
      {open ? "Close" : "Open"}
    </button>
  );
}

Custom hooks are functions whose names start with use and may call other hooks:

Code
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = () => setValue((current) => !current);

  return { value, setValue, toggle };
}

Rules of Hooks

Hooks must be called at the top level of a function component or custom hook.

Do not call hooks:

  • Inside conditions.
  • Inside loops.
  • Inside nested functions.
  • After early returns.
  • In ordinary non-hook functions.
  • In event handlers.

Bad:

Code
function Profile({ enabled }: { enabled: boolean }) {
  if (enabled) {
    const [name, setName] = useState("");
  }

  return null;
}

Good:

Code
function Profile({ enabled }: { enabled: boolean }) {
  const [name, setName] = useState("");

  if (!enabled) {
    return null;
  }

  return <p>{name}</p>;
}

React depends on hooks being called in the same order on every render. Breaking this rule makes React associate state with the wrong hook call.

useState

useState declares local state in a component.

Code
const [value, setValue] = useState(initialValue);

Example:

Code
function SearchInput() {
  const [query, setQuery] = useState("");

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

useState returns two values:

  • The current state for this render.
  • A setter function that schedules the next state and triggers a re-render.

State is local to each component instance. If SearchInput renders twice, each instance has its own query.

State as a Snapshot

State variables are snapshots for a specific render. Calling a setter does not change the variable in the already-running code.

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

  function handleClick() {
    setCount(count + 1);
    console.log(count); // Old value for this render.
  }

  return <button onClick={handleClick}>{count}</button>;
}

React will call the component again with the new state.

If the next state depends on the previous state, use a functional update:

Code
setCount((current) => current + 1);

This matters when queueing multiple updates:

Code
setCount((current) => current + 1);
setCount((current) => current + 1);
setCount((current) => current + 1);

The count increases by three.

Lazy Initial State

If initial state is expensive to compute, pass an initializer function.

Code
function TodoList() {
  const [todos, setTodos] = useState(() => loadInitialTodos());

  return <TodoItems todos={todos} />;
}

Do not call the expensive function directly:

Code
const [todos, setTodos] = useState(loadInitialTodos());

That evaluates on every render, even though React uses the initial value only during initialization.

The initializer function should be pure. In development Strict Mode, React may call initializers more than once to help find impurities.

Updating Objects and Arrays

State should be treated as immutable. Do not mutate existing state objects or arrays.

Bad:

Code
user.name = "Ava";
setUser(user);

Good:

Code
setUser((current) => ({
  ...current,
  name: "Ava",
}));

Array update:

Code
setItems((items) =>
  items.map((item) =>
    item.id === updated.id ? { ...item, ...updated } : item
  )
);

Immutable updates help React detect meaningful changes and keep previous renders predictable.

When useState Is Enough

Use useState when state is simple and updates are easy to understand.

Good useState candidates:

  • A boolean such as isOpen.
  • A string input value.
  • A selected tab.
  • A small object updated in one or two places.
  • Local UI state used by one component.
Code
function Tabs() {
  const [activeTab, setActiveTab] = useState("overview");

  return (
    <>
      <button onClick={() => setActiveTab("overview")}>Overview</button>
      <button onClick={() => setActiveTab("settings")}>Settings</button>
      <Panel activeTab={activeTab} />
    </>
  );
}

Reach for something else when updates become hard to follow, several fields change together, or transitions are better described as actions.

useReducer

useReducer manages state with a reducer function and dispatched actions.

Code
type State = {
  count: number;
};

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return { count: 0 };
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </>
  );
}

The reducer receives the current state and an action, then returns the next state.

When to Use useReducer

useReducer is useful when:

  • State transitions are complex.
  • Several fields update together.
  • Many event handlers update the same state.
  • You want action names to document why state changed.
  • You want reducer logic to be easy to test outside React.

Example form state:

Code
type FormState = {
  email: string;
  password: string;
  status: "idle" | "submitting" | "success" | "error";
};

type FormAction =
  | { type: "fieldChanged"; field: "email" | "password"; value: string }
  | { type: "submitted" }
  | { type: "succeeded" }
  | { type: "failed" };

Reducers make state transitions explicit. This is often clearer than many scattered setState calls.

Reducer Best Practices

Reducers should be pure:

  • Do not mutate state.
  • Do not call APIs.
  • Do not generate random IDs unless the ID is part of an action created outside the reducer.
  • Do not read from the DOM.
  • Return the next state based only on current state and action.

Bad:

Code
function reducer(state: State, action: Action) {
  state.count += 1;
  return state;
}

Good:

Code
function reducer(state: State, action: Action) {
  return { count: state.count + 1 };
}

Keep side effects in event handlers, effects, or command functions outside the reducer.

useContext

useContext reads a value from the nearest matching context provider above the component.

Code
const ThemeContext = createContext<"light" | "dark">("light");

function ThemeButton() {
  const theme = useContext(ThemeContext);

  return <button className={theme}>Save</button>;
}

Provider example:

Code
function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  return (
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
  );
}

React 19 also supports rendering the context object itself as a provider:

Code
<ThemeContext value={theme}>
  <Page />
</ThemeContext>

Many codebases still use .Provider, so interview answers should recognize both.

When to Use Context

Context is useful for values needed by many components at different depths:

  • Theme.
  • Locale.
  • Current user.
  • Feature flags.
  • App shell settings.
  • Shared service clients.

Context is not a replacement for all props. A few levels of explicit props may be simpler than context.

Poor context candidates:

  • State used by only one component.
  • Rapidly changing state for large lists.
  • Form field values that belong in a form component.
  • Values that only one child needs.

Context makes dependencies less visible at the call site. Use it for cross-cutting values, not to avoid ordinary component design.

Context Value Stability

Every provider value change re-renders consumers that read that context.

This value is new every render:

Code
<AuthContext.Provider value={{ user, logout }}>
  {children}
</AuthContext.Provider>

If consumers are expensive or the provider renders often, memoize the value:

Code
const value = useMemo(
  () => ({ user, logout }),
  [user, logout]
);

return (
  <AuthContext.Provider value={value}>
    {children}
  </AuthContext.Provider>
);

Also consider splitting context:

  • AuthUserContext for user data.
  • AuthActionsContext for stable actions.

This prevents unrelated changes from re-rendering consumers unnecessarily.

Safe Context Hooks

When a context has no meaningful default value, use null and expose a custom hook that throws if the provider is missing.

Code
type AuthContextValue = {
  user: User;
  logout: () => void;
};

const AuthContext = createContext<AuthContextValue | null>(null);

function useAuth() {
  const value = useContext(AuthContext);

  if (value === null) {
    throw new Error("useAuth must be used inside AuthProvider");
  }

  return value;
}

This improves caller code because consumers receive AuthContextValue, not AuthContextValue | null.

Custom Hooks

A custom hook is a reusable function that starts with use and calls hooks.

Code
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }

    function handleOffline() {
      setIsOnline(false);
    }

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

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

  return isOnline;
}

Usage:

Code
function StatusBanner() {
  const isOnline = useOnlineStatus();

  return <p>{isOnline ? "Online" : "Offline"}</p>;
}

Custom hooks share logic, not state. Each call to a custom hook has its own state unless it uses a shared external store or context.

Good Custom Hook Boundaries

Extract a custom hook when:

  • Multiple components use the same stateful logic.
  • A component has too much setup or synchronization code.
  • You want to hide implementation details behind a focused API.
  • You want to test logic separately.
  • The hook represents a domain concept such as useAuth, useCart, or useDebouncedValue.

Avoid custom hooks that only rename one line:

Code
function useName() {
  return useState("");
}

This adds indirection without meaningful abstraction.

Good custom hook:

Code
function useDebouncedValue<T>(value: T, delayMs: number) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timeoutId = window.setTimeout(() => {
      setDebounced(value);
    }, delayMs);

    return () => window.clearTimeout(timeoutId);
  }, [value, delayMs]);

  return debounced;
}

Custom Hook Return Shapes

Custom hooks commonly return:

  • A single value.
  • An object with named fields.
  • A tuple for small state-like APIs.

Single value:

Code
const isOnline = useOnlineStatus();

Object:

Code
const { user, login, logout } = useAuth();

Tuple:

Code
const [open, setOpen, toggle] = useDisclosure();

Objects are usually clearer for domain hooks because call sites do not depend on position. Tuples are common when the hook intentionally mirrors built-in hook style.

Common Mistakes

Common mistakes include:

  • Calling hooks conditionally.
  • Calling hooks in event handlers.
  • Mutating state objects or arrays.
  • Using useState for complex transition logic that wants a reducer.
  • Putting side effects inside reducers.
  • Using context to avoid simple prop passing.
  • Providing a new object context value on every render without considering consumers.
  • Forgetting that each custom hook call has separate state.
  • Naming a function that calls hooks without the use prefix.
  • Creating a custom hook abstraction before there is repeated or complex logic.

Best Practices

Use these rules of thumb:

  • Use useState for simple local state.
  • Use functional updates when the next state depends on previous state.
  • Use useReducer for complex state transitions.
  • Keep reducers pure.
  • Use context for cross-cutting values needed across distance.
  • Split context by responsibility and update frequency.
  • Use custom hooks to extract reusable stateful logic.
  • Keep custom hook APIs small and intention-revealing.
  • Follow the Rules of Hooks and use the hooks lint rules.

Interview Practice

PreviousProper useEffect usage and cleanupNext UpError states and retry UX for failed requests