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:
useStatefor local component state.useReducerfor structured state transitions.useContextfor reading shared values from a provider.- Custom hooks for extracting reusable stateful logic.
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:
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:
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:
function Profile({ enabled }: { enabled: boolean }) {
if (enabled) {
const [name, setName] = useState("");
}
return null;
}
Good:
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.
const [value, setValue] = useState(initialValue);
Example:
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.
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:
setCount((current) => current + 1);
This matters when queueing multiple updates:
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.
function TodoList() {
const [todos, setTodos] = useState(() => loadInitialTodos());
return <TodoItems todos={todos} />;
}
Do not call the expensive function directly:
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:
user.name = "Ava";
setUser(user);
Good:
setUser((current) => ({
...current,
name: "Ava",
}));
Array update:
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.
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.
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:
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:
function reducer(state: State, action: Action) {
state.count += 1;
return state;
}
Good:
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.
const ThemeContext = createContext<"light" | "dark">("light");
function ThemeButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>Save</button>;
}
Provider example:
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:
<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:
<AuthContext.Provider value={{ user, logout }}>
{children}
</AuthContext.Provider>
If consumers are expensive or the provider renders often, memoize the value:
const value = useMemo(
() => ({ user, logout }),
[user, logout]
);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
Also consider splitting context:
AuthUserContextfor user data.AuthActionsContextfor 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.
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.
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:
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, oruseDebouncedValue.
Avoid custom hooks that only rename one line:
function useName() {
return useState("");
}
This adds indirection without meaningful abstraction.
Good custom hook:
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:
const isOnline = useOnlineStatus();
Object:
const { user, login, logout } = useAuth();
Tuple:
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
useStatefor 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
useprefix. - Creating a custom hook abstraction before there is repeated or complex logic.
Best Practices
Use these rules of thumb:
- Use
useStatefor simple local state. - Use functional updates when the next state depends on previous state.
- Use
useReducerfor 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.