Goswami Digital WorldBlog & Insights
All PostsMain SiteContact Us
← All Posts·Web Development

React Hooks Explained — useState, useEffect & Beyond

A practical deep-dive into React Hooks. Learn how useState, useEffect, useRef, useMemo, useCallback, and custom hooks work — with real examples you can use today.

A
Ankur Goswami
10 April 2026 · 10 min read
👁views|
❤️likes|
🔗shares
#react#hooks#frontend#javascript#webdev

Before Hooks, managing state and side effects in React meant writing class components — full of boilerplate, lifecycle methods, and this keyword confusion. In 2019, React 16.8 changed everything.

Hooks let you use state, side effects, and other React features inside simple function components. No classes. No this. No confusion.

In this guide, we'll cover every essential Hook with real-world examples — and build a custom Hook from scratch by the end.


1. What Are Hooks?

Hooks are plain JavaScript functions that start with use. They let you "hook into" React features from function components.

The Golden Rules of Hooks

There are only two rules — and they are non-negotiable:

  1. Only call Hooks at the top level — never inside loops, conditions, or nested functions
  2. Only call Hooks from React function components (or custom Hooks)

Break either rule and React's internal state tracking breaks.

// ❌ Wrong — Hook inside a condition
function MyComponent({ isLoggedIn }) {
  if (isLoggedIn) {
    const [name, setName] = useState(""); // React will break
  }
}

// ✅ Correct — Hook always at the top level
function MyComponent({ isLoggedIn }) {
  const [name, setName] = useState("");
  if (!isLoggedIn) return null;
}

2. useState — Managing Local State

useState is the most fundamental Hook. It adds a piece of state to your component and gives you a function to update it.

const [state, setState] = useState(initialValue);

Basic Example

import { useState } from "react";

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

Updating State Based on Previous Value

When your new state depends on the old state, always use the functional update form:

// ❌ Unreliable — may use stale value
setCount(count + 1);

// ✅ Reliable — always uses the latest state
setCount(prev => prev + 1);

State with Objects

function UserForm() {
  const [user, setUser] = useState({ name: "", email: "" });

  const handleChange = (field, value) => {
    // Spread existing state — only update the changed field
    setUser(prev => ({ ...prev, [field]: value }));
  };

  return (
    <div>
      <input
        value={user.name}
        onChange={e => handleChange("name", e.target.value)}
        placeholder="Name"
      />
      <input
        value={user.email}
        onChange={e => handleChange("email", e.target.value)}
        placeholder="Email"
      />
      <p>{user.name} — {user.email}</p>
    </div>
  );
}

Common Mistake: setState replaces state entirely — it does not merge like this.setState in class components. Always spread the previous state when updating objects.


3. useEffect — Handling Side Effects

useEffect runs code after the component renders. It's the right place for side effects — data fetching, subscriptions, manually updating the DOM, and timers.

useEffect(() => {
  // your side effect here

  return () => {
    // optional cleanup (runs before next effect or on unmount)
  };
}, [dependencies]);

The Dependency Array — The Key to useEffect

Dependency Array When the Effect Runs
Not provided After every render
[] (empty) Once — after the first render only
[a, b] After first render + whenever a or b changes

Example: Fetching Data

import { useState, useEffect } from "react";

function UserProfile({ userId }) {
  const [user, setUser]       = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError]     = useState(null);

  useEffect(() => {
    let cancelled = false; // prevents state update on unmounted component

    async function fetchUser() {
      try {
        setLoading(true);
        const res  = await fetch(`/api/users/${userId}`);
        if (!res.ok) throw new Error("Failed to fetch");
        const data = await res.json();
        if (!cancelled) setUser(data);
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchUser();

    return () => { cancelled = true; }; // cleanup on unmount
  }, [userId]); // re-fetch whenever userId changes

  if (loading) return <p>Loading...</p>;
  if (error)   return <p>Error: {error}</p>;
  return <h2>{user?.name}</h2>;
}

Example: Event Listeners & Cleanup

function WindowSize() {
  const [size, setSize] = useState({
    width:  window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };

    window.addEventListener("resize", handleResize);

    // Cleanup — remove listener when component unmounts
    return () => window.removeEventListener("resize", handleResize);
  }, []); // empty array — set up once

  return <p>{size.width} x {size.height}</p>;
}

Always clean up. Forgetting to remove event listeners or cancel subscriptions is one of the most common causes of memory leaks in React apps.


4. useRef — Values That Don't Trigger Re-renders

useRef gives you a mutable container that persists across renders — without causing a re-render when it changes. It has two main uses.

Use 1: Accessing DOM Elements Directly

import { useRef } from "react";

function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus(); // directly access the DOM node
  }, []);

  return <input ref={inputRef} placeholder="I auto-focus on mount" />;
}

Use 2: Storing Values Without Re-renders

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null); // store interval ID — no re-render needed

  const start = () => {
    intervalRef.current = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(intervalRef.current);
  };

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}
useState useRef
Causes re-render when updated No re-render when updated
Use for UI-visible data Use for internal values, timers, DOM refs

5. useMemo — Caching Expensive Calculations

useMemo memoizes the result of an expensive computation — it only recalculates when its dependencies change.

const memoizedValue = useMemo(() => expensiveCalculation(a, b), [a, b]);
import { useState, useMemo } from "react";

function ProductList({ products, searchTerm }) {
  // This filter only re-runs when products or searchTerm changes
  const filteredProducts = useMemo(() => {
    console.log("Filtering..."); // won't spam on every render
    return products.filter(p =>
      p.name.toLowerCase().includes(searchTerm.toLowerCase())
    );
  }, [products, searchTerm]);

  return (
    <ul>
      {filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

Don't over-use useMemo. Memoization itself has a cost. Only use it when a calculation is genuinely slow or when the result is passed as a prop to a memoized child component.


6. useCallback — Caching Functions

useCallback memoizes a function definition — it returns the same function instance across renders unless its dependencies change.

const memoizedFn = useCallback(() => doSomething(a, b), [a, b]);
import { useState, useCallback, memo } from "react";

// Child wrapped in memo — only re-renders if props change
const SearchBar = memo(({ onSearch }) => {
  console.log("SearchBar rendered");
  return <input onChange={e => onSearch(e.target.value)} />;
});

function App() {
  const [query, setQuery]   = useState("");
  const [theme, setTheme]   = useState("light");

  // Without useCallback, this creates a NEW function on every render
  // — causing SearchBar to re-render even when only theme changed
  const handleSearch = useCallback((value) => {
    setQuery(value);
  }, []); // stable reference — no dependencies

  return (
    <div>
      <SearchBar onSearch={handleSearch} />
      <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
        Toggle Theme ({theme})
      </button>
      <p>Query: {query}</p>
    </div>
  );
}

7. useContext — Sharing State Without Prop Drilling

useContext lets you read a value from a Context — avoiding the need to pass props through every level of the component tree.

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

// 1. Create the context
const ThemeContext = createContext("light");

// 2. Provide it at the top level
function App() {
  const [theme, setTheme] = useState("light");

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Navbar />
      <MainContent />
    </ThemeContext.Provider>
  );
}

// 3. Consume it anywhere in the tree — no props needed
function Navbar() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <nav style={{ background: theme === "dark" ? "#111" : "#fff" }}>
      <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
        Toggle Theme
      </button>
    </nav>
  );
}

Context is not a state management library. It's great for low-frequency updates like theme or auth status. For complex, high-frequency state, consider Zustand or Redux Toolkit instead.


8. Custom Hooks — Reusable Logic

Custom Hooks let you extract and reuse stateful logic across multiple components. Any function that starts with use and calls other Hooks is a custom Hook.

Example: useFetch

// hooks/useFetch.js
import { useState, useEffect } from "react";

function useFetch(url) {
  const [data, setData]       = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError]     = useState(null);

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

    async function fetchData() {
      try {
        setLoading(true);
        setError(null);
        const res  = await fetch(url);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const json = await res.json();
        if (!cancelled) setData(json);
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchData();
    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

export default useFetch;
// Use it in any component — clean and reusable
function Posts() {
  const { data, loading, error } = useFetch("/api/posts");

  if (loading) return <p>Loading...</p>;
  if (error)   return <p>Error: {error}</p>;

  return (
    <ul>
      {data.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
}

Example: useLocalStorage

// hooks/useLocalStorage.js
import { useState } from "react";

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (err) {
      console.error(err);
    }
  };

  return [storedValue, setValue];
}

export default useLocalStorage;
// Works exactly like useState — but persists across page refreshes
function Settings() {
  const [theme, setTheme] = useLocalStorage("theme", "light");

  return (
    <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
      Current Theme: {theme}
    </button>
  );
}

9. Common Mistakes to Avoid

Mistake 1: Missing Dependencies in useEffect

// ❌ userId is used but not listed — stale closure bug
useEffect(() => {
  fetchUser(userId);
}, []);

// ✅ List every value used inside the effect
useEffect(() => {
  fetchUser(userId);
}, [userId]);

Mistake 2: Creating Objects/Arrays Inline as Dependencies

// ❌ New object created on every render — infinite loop
useEffect(() => {
  fetchData(options);
}, [{ page: 1, limit: 10 }]); // new reference every render!

// ✅ Stable primitive dependencies
const page  = 1;
const limit = 10;
useEffect(() => {
  fetchData({ page, limit });
}, [page, limit]);

Mistake 3: Overusing useEffect for Derived State

// ❌ Unnecessary effect — derived state shouldn't live in useEffect
const [firstName, setFirstName] = useState("Ankur");
const [lastName,  setLastName]  = useState("Goswami");
const [fullName,  setFullName]  = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`); // extra render, not needed
}, [firstName, lastName]);

// ✅ Just compute it during render — no effect needed
const fullName = `${firstName} ${lastName}`;

10. Quick Reference

Hook Purpose
useState Local component state
useEffect Side effects — fetch, subscriptions, timers
useRef DOM access or mutable value without re-render
useMemo Cache expensive calculated values
useCallback Cache function references for stable props
useContext Read from React Context — avoid prop drilling
Custom Hook Extract & reuse stateful logic across components

Hooks fundamentally changed how we write React. Once you internalize these patterns — especially building your own custom Hooks — you'll find yourself writing cleaner, more reusable, and far more maintainable components.

The best way to learn them is to build: take one component from your current project and refactor it using the patterns from this guide. 🚀

Up next: React Performance Optimization — memo, lazy, Suspense & Profiler.

Enjoyed this post?
...
Share this post
💬𝕏inF
← Back to BlogWork With Us →