Skip to main content
Madhukar
All Articles

React Hooks Masterclass: useState, useEffect, and Custom Hooks

July 22, 20267 min read
FrontendReactJavaScriptReact Hook
React Hooks Masterclass: useState, useEffect, and Custom Hooks

How does React remember information between renders?

A regular JavaScript function forgets everything the moment it returns. Call it again, and every local variable starts fresh. But a React component clearly doesn’t forget — a counter keeps counting, a form keeps its typed text, a toggle stays toggled, across dozens of re-renders.

Hooks are the mechanism that makes this possible — React’s way of letting a function component hold onto memory (useState) and reach outside itself to synchronize with the world (useEffect), without ever needing to become a class. This guide builds both from first principles, then shows how to package reusable logic into your own custom hooks.

1. Why React Hooks Were Introduced

Problems with older React patterns

Before Hooks, stateful logic required class components — more verbose syntax, a confusing this binding, and lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount) that often forced related logic (like setting up and tearing down a subscription) to be split awkwardly across separate methods.

Reusing logic between components

Sharing stateful logic between class components typically meant patterns like higher-order components or render props — functional, but often adding extra wrapper layers and indirection just to reuse a small piece of behavior.

Simpler component development

Hooks let function components — already simpler and more readable than classes — hold state and perform side effects directly, removing the need to reach for a class at all for the vast majority of components.

Benefits of hooks

  • Related logic (setup and cleanup for the same concern) can live together in one place, instead of split across separate lifecycle methods
  • Reusable stateful logic can be extracted into plain functions (custom hooks) — no wrapper components, no indirection
  • Simpler mental model overall: components are just functions, enhanced with a few special capabilities

Modern React development

Hooks are now the default way to write React — new codebases are built almost entirely with function components and hooks, with class components mostly encountered only in older, legacy code.

2. Understanding useState

What useState is

useState is a hook that gives a function component a piece of memory that persists across re-renders — something a plain function normally can't do.

Creating component state

const [count, setCount] = useState(0);

This returns a pair: the current value (count), and a function to update it (setCount). The 0 passed in is the initial value, used only on the very first render.

Updating state

<button onClick={() => setCount(count + 1)}>Increment</button>

Calling setCount doesn't just change a variable — it tells React "this component's data changed, please re-render it with the new value."

Multiple state variables

A component can hold as many independent useState calls as it needs:

function ProfileForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [isEditing, setIsEditing] = useState(false);
// ...
}

Each behaves independently — updating one doesn’t affect the others.

State-driven UI

The UI a component renders should always be a direct reflection of its current state — as covered in this series’s React Fundamentals article, this is the core of React’s declarative model.

Common state management patterns

  • Boolean state for toggles (isOpen, isLoading)
  • Object or array state for more complex, grouped data
  • Deriving values from existing state during render, rather than storing every derived value as its own separate state

3. Understanding React Re-renders

What triggers a re-render

A component re-renders when its own state changes, when its props change, or when its parent re-renders — the same model covered in this series’s state-management article, worth restating here since it’s the direct consequence of every useState call.

State updates and rendering

Every call to a state setter schedules a re-render of that component (and, by default, its children) — this is the trigger that turns “data changed” into “UI updated.”

How React updates the UI

Behind every re-render sits the Virtual DOM and reconciliation process covered in this series’s dedicated internals article — React re-runs the component, builds a new Virtual DOM tree, and applies only the real changes needed.

Avoiding unnecessary state

Not every value needs its own useState. A value that can be directly computed from existing state or props during render doesn't need to be duplicated into its own state variable — doing so risks the copy silently drifting out of sync with its source.

Common misconceptions

A common misunderstanding: that calling a state setter updates the DOM immediately, synchronously. In reality, React schedules the update and re-renders on its own timing — code immediately after a setState call still sees the old value in that same render.

function handleClick() {
console.log(count); // still the old value here
setCount(count + 1);
console.log(count); // still the old value here too — the update hasn't applied yet
}

4. Understanding useEffect

Why useEffect exists

Not everything a component needs to do fits neatly into “render some JSX from the current state.” Fetching data, subscribing to an external event, or setting a timer are all things that happen alongside rendering, reaching outside the component to the wider world — these are called side effects, and useEffect is how React lets you perform them safely.

Side effects in applications

A side effect is any operation that affects (or depends on) something outside the render itself: a network request, a subscription, direct DOM manipulation, or a timer.

Data fetching

useEffect(() => {
let ignore = false;

fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!ignore) {
setUser(data);
}
});

return () => {
ignore = true;
};
}, [userId]);

Avoiding race conditions: If userId changes before an earlier request finishes, the older request may resolve later and overwrite the newer data. The cleanup function marks the previous request as ignored, preventing stale responses from updating the component's state. This ensures that only the latest response updates the UI.

Event subscriptions

useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}

window.addEventListener("resize", handleResize);

return () => {
window.removeEventListener("resize", handleResize);
};
}, []);

Timers and intervals

useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);

return () => {
clearInterval(id);
};
}, []);

Cleanup functions

Effects that set something up (a subscription, a timer, an event listener) usually need to tear it down too — otherwise it keeps running even after the component is gone. Returning a function from useEffect tells React what to run for cleanup:

useEffect(() => {
const id = setInterval(() => setSeconds(s => s + 1), 1000);

return () => clearInterval(id);
}, []);

5. Dependency Arrays Explained

What dependency arrays are

The array passed as useEffect's second argument tells React when to re-run the effect — specifically, only when one of the listed values has changed since the last render.

Running effects once

An empty array ([]) means the effect has no dependencies that ever change, so it runs only once, right after the very first render:

useEffect(() => {
console.log("Component mounted");
}, []);

Running effects on changes

Listing specific values means the effect re-runs whenever any of them changes between renders:

useEffect(() => {
fetchUser(userId);
}, [userId]); // re-runs whenever userId changes

Common dependency mistakes

  • Omitting a value the effect actually uses — the effect keeps using a stale, outdated version of that value instead of the current one
  • Leaving off the array entirely — the effect then runs after every single render, which is rarely what’s intended
  • Passing a new object or array literal as a dependency — since it’s a new reference every render, the effect re-runs every time, even if its actual contents haven’t meaningfully changed

Avoiding infinite loops

A classic bug: an effect updates a piece of state that’s also listed in its own dependency array, without a condition to stop — each run triggers a re-render, which re-triggers the effect, forever.

// Bug: runs forever
useEffect(() => {
setCount(count + 1);
}, [count]);

6. Common useEffect Patterns

Fetching data from APIs

The most common pattern: fetch when a component mounts or when a relevant ID/parameter changes, storing the result in state to trigger a re-render with the new data.

Listening to browser events

Subscribing to window resize, scroll position, or keyboard events — always paired with a cleanup function removing that same listener.

Synchronizing external systems

A genuinely useful way to think about useEffect: it's not really a "lifecycle hook" in the old class-component sense — it's a tool for keeping some external system in sync with your component's current props and state (the browser's title, a subscription, a timer, a piece of local storage).

Cleanup best practices

Always clean up anything an effect sets up that outlives a single render — event listeners, timers, subscriptions — to avoid duplicated listeners or timers silently accumulating across re-renders.

Lifecycle thinking in React

Rather than asking “what class lifecycle method does this belong in,” ask: “what does this effect depend on, and does it need to clean anything up before running again?” That framing maps directly onto how useEffect and its dependency array actually work.

7. Custom Hooks

What custom hooks are

A custom hook is simply a regular JavaScript function, by convention named starting with use, that itself calls other hooks (useState, useEffect, or other custom hooks) inside it.

function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);

useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);

return width;
}

Why custom hooks exist

They let stateful logic — not just plain utility functions, but logic that itself uses useState or useEffect — be extracted and reused across multiple components, without duplicating that logic or resorting to wrapper components.

Reusing stateful logic

Any component that needs the current window width can now simply do:

function Sidebar() {
const width = useWindowWidth();
return <div>{width < 768 ? "Mobile Layout" : "Desktop Layout"}</div>;
}

Separating concerns

Custom hooks let a component’s rendering logic (its JSX) stay focused and readable, while more complex stateful logic (data fetching, subscriptions, calculations) lives in its own clearly named, testable function.

Building reusable abstractions

Well-designed custom hooks hide their internal implementation details behind a simple, clear return value — the components using them don’t need to know how useWindowWidth works, only what it gives back.

8. When to Create Custom Hooks

Shared business logic

If the same stateful logic — calculating something, tracking a status, coordinating multiple pieces of state — shows up in more than one component, it’s a strong candidate for extraction into a custom hook.

Data fetching logic

A useFetch(url) or useUser(userId) custom hook can wrap the fetch-and-store-in-state pattern once, reused everywhere data needs to be loaded the same way.

Authentication logic

A useAuth() hook can centralize checking the current logged-in user, exposing simple values (user, isLoggedIn, login(), logout()) without every component needing to know how authentication is actually implemented underneath.

Form management

A useForm() hook can handle common form concerns — field values, validation state, submission handling — once, instead of rebuilding that logic in every form component.

Window and device utilities

Things like window size, online/offline status, or media query matches are commonly extracted into small, focused custom hooks (useWindowWidth, useOnlineStatus, useMediaQuery) reused across an application.

9. Hooks Rules and Best Practices

Rules of hooks

  • Only call hooks at the top level of a component or custom hook — never inside loops, conditions, or nested functions
  • Only call hooks from React function components or other custom hooks — never from a regular JavaScript function

Predictable execution

These rules exist because React tracks each hook’s state by the order they’re called in, across renders. Calling them conditionally could change that order between renders, silently corrupting which state belongs to which useState call.

// Wrong: conditional hook call
if (isLoggedIn) {
const [name, setName] = useState(""); // breaks the call order across renders
}

// Correct: hook called unconditionally, condition handled inside
const [name, setName] = useState("");
if (isLoggedIn) {
// use name here
}

Organizing hooks

Grouping related useState and useEffect calls together, and extracting genuinely reusable or complex logic into custom hooks, keeps components readable as they grow.

Avoiding common mistakes

Beyond the rules above: forgetting cleanup functions, missing dependencies in a dependency array, and creating new object/function references on every render that inadvertently trigger effects or child re-renders unnecessarily.

Building maintainable components

A component that clearly separates “what state and effects does this component need” (via well-named hooks, custom where appropriate) from “what does this component render” tends to stay readable even as it grows in complexity.

10. Thinking in Hooks

State management mindset

Ask, for each piece of data: does this need to trigger a re-render when it changes? If yes, it likely belongs in useState (or a custom hook wrapping it). If it can be derived from existing state or props, compute it during render instead of storing it separately.

Effect management mindset

Ask, for each side effect: what external system am I synchronizing with, and what does that synchronization depend on? The dependency array should honestly reflect everything the effect reads — not be trimmed down just to avoid re-runs.

Logic reuse strategies

When the same combination of state and effects shows up in multiple components, that’s the signal to extract a custom hook — packaging the behavior, not just a single value, into a clearly named, reusable function.

Modern React architecture

Hooks shifted React’s architecture toward composition: instead of inheriting behavior through class hierarchies, components compose small, focused hooks — built-in or custom — to assemble exactly the state and effects they need, and nothing more.

Final Takeaway

Hooks answer two separate but related questions a component constantly needs to answer: “what do I need to remember between renders?” (useState) and "what do I need to keep in sync with something outside myself?" (useEffect). Once those two ideas click, dependency arrays stop feeling like a rule to memorize and start feeling like an honest list of "what does this synchronization actually depend on." And custom hooks are simply what happens when that same combination of state and effects is worth naming and reusing — turning a repeated pattern into a clear, composable piece of your application's architecture.

Frequently Asked Questions

Do I need to list every value used inside useEffect in its dependency array?

> Generally yes — omitting a value the effect actually reads means the effect can run with a stale, outdated version of it. If a dependency genuinely shouldn’t trigger a re-run, that’s usually a sign the effect’s logic needs restructuring, not that the dependency should be hidden.

What’s the difference between useEffect and just running code directly in the component body?

> Code in the component body runs on every render, synchronously, as part of producing the JSX. useEffect runs after the render has been committed to the screen, and specifically for synchronizing with things outside React's own rendering — network requests, subscriptions, manual DOM work.

Can a custom hook use another custom hook?

> Yes — custom hooks can freely call other custom hooks (as well as built-in ones), which is exactly how more complex, composed behavior gets built from smaller, focused pieces.

Why can’t hooks be called inside an if statement?

> Because React relies on hooks being called in the exact same order on every render to correctly match each hook call to its stored state. A conditional hook call can change that order between renders, causing React to associate the wrong state with the wrong hook.

Originally published by Mr Madhukar

Read the complete article on Medium with full formatting & reader responses.