DEV Community

Cover image for React Mastery Series — Part 3: React Hooks — The Complete Practical Guide
Hamed Farazi
Hamed Farazi

Posted on

React Mastery Series — Part 3: React Hooks — The Complete Practical Guide

React Hooks are one of the most important concepts in modern React development.

They allow functional components to use state, manage side effects, access context, reference DOM elements, optimize performance, and reuse stateful logic.

Before Hooks were introduced, many React features required class components. Hooks changed that by making powerful React capabilities available directly inside function components.

In this guide, we'll explore the most important React Hooks, understand when to use them, avoid common mistakes, and build practical examples.

By the end, you'll understand not only how Hooks work, but also how to choose the right Hook for a particular problem.


1. What Are React Hooks?

Hooks are special functions provided by React that let function components use React features.

For example:

import { useState } from "react";

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

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here, useState allows a function component to remember a value between renders.

Without Hooks, function components were primarily used for presenting UI based on props.

With Hooks, function components can manage much more complex behavior.

Why Were Hooks Introduced?

Hooks help solve several problems:

  • Reusing stateful logic between components.
  • Avoiding large and complicated class components.
  • Organizing related logic together.
  • Managing component state and lifecycle behavior.
  • Making React code easier to understand and maintain.

The most important idea is that Hooks let you organize component logic around features rather than lifecycle methods.


2. Rules of Hooks

Before learning individual Hooks, you must understand two important rules.

Rule 1: Only Call Hooks at the Top Level

Never call Hooks inside:

  • Conditions.
  • Loops.
  • Nested functions.
  • Event handlers.
  • try / catch blocks.

Incorrect:

function Profile({ isLoggedIn }) {
  if (isLoggedIn) {
    const [name, setName] = useState("Alex");
  }

  return <h1>Profile</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Correct:

function Profile({ isLoggedIn }) {
  const [name, setName] = useState("Alex");

  if (!isLoggedIn) {
    return <p>Please log in.</p>;
  }

  return <h1>Hello, {name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Why?

React relies on the order of Hook calls to associate Hook state with the correct component.

If Hooks are called conditionally, their order can change between renders, causing bugs.

Rule 2: Only Call Hooks from React Functions

Hooks should be called from:

  1. React function components.
  2. Custom Hooks.

Incorrect:

function calculateSomething() {
  const [value, setValue] = useState(0);
}
Enter fullscreen mode Exit fullscreen mode

Correct:

function useCounter() {
  const [value, setValue] = useState(0);

  return { value, setValue };
}
Enter fullscreen mode Exit fullscreen mode

Custom Hooks are discussed later in this article.


3. useState — Managing Component State

useState is probably the Hook you'll use most frequently.

It allows a component to store information that can change over time.

Basic Syntax

const [state, setState] = useState(initialValue);
Enter fullscreen mode Exit fullscreen mode

It returns an array containing:

  • The current state value.
  • A function for updating that value.

Example:

import { useState } from "react";

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

  return (
    <div>
      <h2>Count: {count}</h2>

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

      <button onClick={() => setCount(count - 1)}>
        Decrease
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When setCount is called, React schedules an update and renders the component with the new state.

Updating State Based on Previous State

Sometimes you need to calculate the next state using the previous state.

In this situation, use the functional updater form.

setCount(previousCount => previousCount + 1);
Enter fullscreen mode Exit fullscreen mode

For example:

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

  function increaseThreeTimes() {
    setCount(value => value + 1);
    setCount(value => value + 1);
    setCount(value => value + 1);
  }

  return (
    <div>
      <h2>{count}</h2>

      <button onClick={increaseThreeTimes}>
        Increase by 3
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why use this approach?

State values are snapshots associated with a particular render. Multiple updates that depend on the previous value should use updater functions to avoid calculating from an outdated snapshot.

Managing Objects with useState

You can also store objects in state.

function UserProfile() {
  const [user, setUser] = useState({
    name: "Alex",
    age: 25,
    city: "Berlin"
  });

  function updateAge() {
    setUser(previousUser => ({
      ...previousUser,
      age: previousUser.age + 1
    }));
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <p>Age: {user.age}</p>
      <p>City: {user.city}</p>

      <button onClick={updateAge}>
        Increase Age
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice the use of the spread operator:

{
  ...previousUser,
  age: previousUser.age + 1
}
Enter fullscreen mode Exit fullscreen mode

React state should be treated as immutable.

Instead of modifying the existing object, create a new object with the updated properties.

Managing Arrays with useState

function TodoList() {
  const [todos, setTodos] = useState([
    "Learn React",
    "Practice JavaScript"
  ]);

  function addTodo() {
    setTodos(previousTodos => [
      ...previousTodos,
      "Build a project"
    ]);
  }

  return (
    <div>
      <ul>
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>

      <button onClick={addTodo}>
        Add Todo
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

For lists that can be reordered, inserted into, or deleted from, use stable unique IDs instead of array indexes as keys.


4. useEffect — Synchronizing with External Systems

useEffect is one of the most misunderstood React Hooks.

It allows a component to synchronize with systems outside React.

Examples include:

  • Fetching data from an API.
  • Subscribing to browser events.
  • Connecting to a WebSocket.
  • Starting and cleaning up timers.
  • Synchronizing with third-party libraries.
  • Updating certain external browser APIs.

Basic Syntax

useEffect(() => {
  // Synchronization logic

  return () => {
    // Cleanup logic
  };
}, [dependencies]);
Enter fullscreen mode Exit fullscreen mode

The dependency array determines when React needs to re-synchronize the Effect.

Example 1: An Effect After Every Commit

import { useEffect, useState } from "react";

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

  useEffect(() => {
    console.log("Component committed.");
  });

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

There is no dependency array.

The Effect runs after every commit of this component.

Example 2: An Effect with an Empty Dependency Array

useEffect(() => {
  console.log("Effect setup");

}, []);
Enter fullscreen mode Exit fullscreen mode

This Effect has no reactive dependencies.

It runs when the component mounts, and its cleanup runs when the component unmounts.

In development, React Strict Mode may perform an additional setup-and-cleanup cycle to help reveal bugs. Therefore, don't assume an Effect's setup always runs exactly once in every environment.

Example 3: An Effect with Dependencies

function UserProfile({ userId }) {
  useEffect(() => {
    console.log("User ID changed:", userId);
  }, [userId]);

  return <h2>User: {userId}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

The Effect runs after the initial commit and again when userId changes.

Understanding Dependency Arrays

Dependency array When the Effect runs
No array After every commit
[] After initial mount
[value] After mount and when value changes

React compares dependency values using Object.is.

Every reactive value used by an Effect should be included in its dependency array.

Don't remove dependencies just to force an Effect to run less frequently. Instead, restructure the code so that the Effect genuinely depends only on the values it needs.


5. useEffect Cleanup

Some Effects create resources that must be released.

For example:

  • Event listeners.
  • Timers.
  • WebSocket connections.
  • Subscriptions.

React lets you return a cleanup function from an Effect.

Example:

import { useEffect } from "react";

function WindowWidth() {
  useEffect(() => {
    function handleResize() {
      console.log(window.innerWidth);
    }

    window.addEventListener("resize", handleResize);

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

  return <p>Resize the browser window.</p>;
}
Enter fullscreen mode Exit fullscreen mode

The cleanup function runs:

  1. Before the Effect runs again with changed dependencies.
  2. When the component unmounts.

This prevents old subscriptions and event listeners from remaining active.

Practical Example: A Timer

import { useEffect, useState } from "react";

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setSeconds(previous => previous + 1);
    }, 1000);

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

  return <h2>Seconds: {seconds}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

The cleanup function stops the interval when the component is removed.


6. Fetching API Data with useEffect

A common use case for Effects is synchronizing a component with data from an API.

Let's build a simple example.

import { useEffect, useState } from "react";

function Users() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    async function fetchUsers() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(
          "https://jsonplaceholder.typicode.com/users",
          { signal: controller.signal }
        );

        if (!response.ok) {
          throw new Error("Failed to fetch users");
        }

        const data = await response.json();

        setUsers(data);
      } catch (error) {
        if (error.name !== "AbortError") {
          setError(error.message);
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }

    fetchUsers();

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

  if (loading) {
    return <p>Loading users...</p>;
  }

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

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name} — {user.email}
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

This example demonstrates several important concepts:

  • Loading state.
  • Error handling.
  • API requests.
  • Effect cleanup.
  • Request cancellation.
  • Rendering dynamic data.

Important: useEffect is not automatically the best solution for every data-fetching requirement. Larger applications often benefit from framework data-loading features or dedicated libraries that handle caching, request deduplication, retries, and server rendering.

A Common useEffect Mistake

Avoid using Effects to calculate values that can be calculated directly during rendering.

Unnecessary:

function Product({ price, quantity }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(price * quantity);
  }, [price, quantity]);

  return <p>Total: {total}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Better:

function Product({ price, quantity }) {
  const total = price * quantity;

  return <p>Total: {total}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Why?

The total is derived from existing props. It doesn't need separate state or an Effect.

Use Effects for synchronization with external systems, not as a general mechanism for responding to every change in React data.


7. useRef — Referencing DOM Elements and Storing Mutable Values

useRef creates an object that persists between renders.

Its .current property can be changed without triggering a re-render.

Basic Syntax

const myRef = useRef(initialValue);
Enter fullscreen mode Exit fullscreen mode

Example 1: Accessing a DOM Element

import { useRef } from "react";

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

  function focusInput() {
    inputRef.current.focus();
  }

  return (
    <div>
      <input ref={inputRef} type="text" />

      <button onClick={focusInput}>
        Focus Input
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's understand what happens:

  1. useRef(null) creates a reference object.
  2. The object is assigned to the input using the ref attribute.
  3. After React attaches the DOM element, inputRef.current points to it.
  4. Calling .focus() focuses the input.

Example 2: Storing a Previous Value

import { useEffect, useRef } from "react";

function Counter({ count }) {
  const previousCount = useRef(count);

  useEffect(() => {
    previousCount.current = count;
  }, [count]);

  return (
    <div>
      <p>Current: {count}</p>
      <p>Previous: {previousCount.current}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The ref stores a value between renders without causing another render when it changes.

useState vs useRef

Feature useState useRef
Stores values Yes Yes
Persists between renders Yes Yes
Updating triggers render Yes No
Common use UI state DOM references and mutable values

Remember: If changing a value should update the UI, state is usually the right choice. If the value must persist but doesn't need to trigger rendering, a ref may be appropriate.


8. useContext — Sharing Data Across Components

Sometimes multiple components need access to the same information.

For example:

  • Current user.
  • Theme settings.
  • Language preferences.
  • Application configuration.

Passing props through many intermediate components can become inconvenient. This is often called prop drilling.

React Context provides a way to share values across a component tree.

Step 1: Create a Context

import { createContext } from "react";

export const ThemeContext = createContext("light");
Enter fullscreen mode Exit fullscreen mode

Step 2: Provide the Context Value

import { ThemeContext } from "./ThemeContext";

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Dashboard />
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Consume the Context

import { useContext } from "react";
import { ThemeContext } from "./ThemeContext";

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

  return (
    <div>
      <h1>Current theme: {theme}</h1>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the Dashboard component can access the theme without receiving it through props.

Context with State

Context can also share state.

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

const AuthContext = createContext(null);

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  function login(username) {
    setUser({ username });
  }

  function logout() {
    setUser(null);
  }

  return (
    <AuthContext.Provider
      value={{ user, login, logout }}
    >
      {children}
    </AuthContext.Provider>
  );
}

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

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

  return context;
}
Enter fullscreen mode Exit fullscreen mode

A component can now consume the shared authentication state:

function Profile() {
  const { user, logout } = useAuth();

  if (!user) {
    return <p>Please log in.</p>;
  }

  return (
    <div>
      <h2>Welcome, {user.username}</h2>
      <button onClick={logout}>Logout</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wrap the application with the provider:

function App() {
  return (
    <AuthProvider>
      <Profile />
    </AuthProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Context is useful for sharing values across a component tree, but it isn't automatically a replacement for every state-management solution.

For frequently changing, complex application state, consider whether a dedicated state-management approach is more appropriate.


9. useReducer — Managing Complex State Logic

useReducer is an alternative to useState.

It is particularly useful when:

  • State has multiple related properties.
  • Many actions can change the state.
  • Update logic is becoming complicated.
  • You want to centralize state transitions.

Basic Syntax

const [state, dispatch] = useReducer(reducer, initialState);
Enter fullscreen mode Exit fullscreen mode

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

Example: Counter with useReducer

import { useReducer } from "react";

const initialState = {
  count: 0
};

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        ...state,
        count: state.count + 1
      };

    case "decrement":
      return {
        ...state,
        count: state.count - 1
      };

    case "reset":
      return initialState;

    default:
      throw new Error("Unknown action");
  }
}

function Counter() {
  const [state, dispatch] = useReducer(
    reducer,
    initialState
  );

  return (
    <div>
      <h2>{state.count}</h2>

      <button
        onClick={() => dispatch({ type: "increment" })}
      >
        Increase
      </button>

      <button
        onClick={() => dispatch({ type: "decrement" })}
      >
        Decrease
      </button>

      <button
        onClick={() => dispatch({ type: "reset" })}
      >
        Reset
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

How Does It Work?

When a user clicks the Increase button:

  1. The event handler calls dispatch.
  2. React sends the action to the reducer.
  3. The reducer calculates the next state.
  4. React renders the component with the updated state.

useState vs useReducer

Use useState when state updates are relatively simple.

Use useReducer when state transitions involve multiple actions or more centralized logic.

Neither is universally better. Choose based on the complexity of the state.


10. useMemo — Optimizing Expensive Calculations

useMemo allows React to cache the result of a calculation between renders.

It is useful when a calculation is expensive and its inputs don't change on every render.

Basic Syntax

const cachedValue = useMemo(() => {
  return expensiveCalculation();
}, [dependencies]);
Enter fullscreen mode Exit fullscreen mode

Example

import { useMemo, useState } from "react";

function ProductList({ products }) {
  const [search, setSearch] = useState("");

  const filteredProducts = useMemo(() => {
    console.log("Filtering products...");

    return products.filter(product =>
      product.name
        .toLowerCase()
        .includes(search.toLowerCase())
    );
  }, [products, search]);

  return (
    <div>
      <input
        value={search}
        onChange={event => setSearch(event.target.value)}
        placeholder="Search products..."
      />

      <ul>
        {filteredProducts.map(product => (
          <li key={product.id}>
            {product.name}
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The filtered result can be reused while both products and search remain unchanged.

Important Notes

useMemo is a performance optimization, not a requirement for correct code.

Don't wrap every calculation in useMemo.

For simple operations, normal JavaScript is usually clearer:

const fullName = firstName + " " + lastName;
Enter fullscreen mode Exit fullscreen mode

There is no reason to memoize every small calculation.

Also, React may discard memoized values in certain situations. Your code should remain correct even if a calculation runs again.


11. useCallback — Caching Function References

useCallback lets React cache a function definition between renders.

It is useful when function identity matters, particularly when passing callbacks to memoized child components or using functions as dependencies.

Basic Syntax

const cachedFunction = useCallback(() => {
  // Function logic
}, [dependencies]);
Enter fullscreen mode Exit fullscreen mode

Example

import { useCallback, useState } from "react";

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

  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []);

  return (
    <div>
      <h2>{count}</h2>

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

      <Child onClick={handleClick} />
    </div>
  );
}

function Child({ onClick }) {
  console.log("Child rendered");

  return (
    <button onClick={onClick}>
      Child Button
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this example, handleClick keeps the same function identity between renders.

However, this alone doesn't prevent Child from rendering.

To skip some child renders when its props remain unchanged, you can use memo:

import { memo } from "react";

const Child = memo(function Child({ onClick }) {
  console.log("Child rendered");

  return (
    <button onClick={onClick}>
      Child Button
    </button>
  );
});
Enter fullscreen mode Exit fullscreen mode

Now React can skip rendering the child when its props are unchanged.

useMemo vs useCallback

Hook What it caches
useMemo A calculated value
useCallback A function definition

A useful mental model:

useMemo(() => calculateValue(), [dependency]);

useCallback(() => handleAction(), [dependency]);
Enter fullscreen mode Exit fullscreen mode

Both are optimization tools. Use them when there is a measurable or meaningful reason, not automatically.

In projects using React Compiler, some manual memoization may be handled automatically, depending on the project's configuration and supported features.


12. useId — Generating Unique IDs for Accessibility

useId generates unique IDs that are useful for connecting related accessibility elements.

For example, an input and its label.

import { useId } from "react";

function EmailInput() {
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>
        Email Address
      </label>

      <input
        id={id}
        type="email"
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

React generates an ID that can connect the label to the input.

This is particularly useful when a reusable component appears multiple times.

Important: Don't use useId to generate list keys.

List keys should come from stable data identifiers.


13. useTransition — Keeping the UI Responsive

Sometimes an update triggers expensive rendering work.

You may want an interaction, such as typing into an input, to remain responsive while another update happens in the background.

useTransition allows you to mark certain state updates as non-urgent.

Example

import {
  useState,
  useTransition
} from "react";

function SearchPage({ items }) {
  const [query, setQuery] = useState("");
  const [filteredQuery, setFilteredQuery] = useState("");

  const [isPending, startTransition] = useTransition();

  function handleChange(event) {
    const value = event.target.value;

    setQuery(value);

    startTransition(() => {
      setFilteredQuery(value);
    });
  }

  const filteredItems = items.filter(item =>
    item.toLowerCase().includes(filteredQuery.toLowerCase())
  );

  return (
    <div>
      <input
        value={query}
        onChange={handleChange}
        placeholder="Search..."
      />

      {isPending && <p>Updating results...</p>}

      <ul>
        {filteredItems.map(item => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The input update is urgent, while the filtering-related state update is marked as a transition.

Important Concepts

useTransition returns:

  • isPending: indicates whether a transition is pending.
  • startTransition: marks state updates as transitions.

A transition is not a replacement for debouncing, and it doesn't automatically make expensive JavaScript calculations faster.

It helps React prioritize rendering work.


14. useDeferredValue — Deferring Updates

useDeferredValue lets a component defer updating part of its UI.

It is useful when a value changes frequently and rendering everything based on that value is expensive.

Example

import {
  useDeferredValue,
  useState
} from "react";

function Search({ items }) {
  const [query, setQuery] = useState("");

  const deferredQuery = useDeferredValue(query);

  const filteredItems = items.filter(item =>
    item.toLowerCase().includes(
      deferredQuery.toLowerCase()
    )
  );

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

      <ul>
        {filteredItems.map(item => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The input can update immediately while the result-rendering portion can temporarily use an older value.

This can improve responsiveness when the deferred part is expensive to render.

useTransition vs useDeferredValue

Hook Main purpose
useTransition Mark state updates as non-urgent
useDeferredValue Defer updating a value used by rendering

Use useTransition when you control the state update.

Use useDeferredValue when you receive a value and want to defer how your component uses it.


15. Custom Hooks — Reusing Stateful Logic

One of the most powerful features of Hooks is the ability to create your own Hooks.

A Custom Hook is a JavaScript function whose name starts with use and that can call other Hooks.

Custom Hooks help you extract reusable behavior from components.

Example: useWindowWidth

Imagine several components need to know the browser window width.

Instead of repeating the same Effect everywhere, create a Custom Hook.

import {
  useEffect,
  useState
} from "react";

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;
}
Enter fullscreen mode Exit fullscreen mode

Now any component can use it:

function ResponsiveLayout() {
  const width = useWindowWidth();

  return (
    <div>
      <h2>Window Width: {width}px</h2>

      {width < 768 ? (
        <p>Mobile Layout</p>
      ) : (
        <p>Desktop Layout</p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Example: useLocalStorage

A reusable Hook can synchronize a state value with browser storage.

import {
  useEffect,
  useState
} from "react";

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const storedValue = localStorage.getItem(key);

    return storedValue !== null
      ? JSON.parse(storedValue)
      : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function Settings() {
  const [theme, setTheme] = useLocalStorage(
    "theme",
    "light"
  );

  return (
    <div>
      <h2>Current Theme: {theme}</h2>

      <button
        onClick={() =>
          setTheme(theme === "light" ? "dark" : "light")
        }
      >
        Toggle Theme
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is a simplified browser-only implementation. In server-rendered applications, browser APIs such as localStorage must be accessed only in an appropriate client-side environment. Production implementations should also consider invalid stored JSON and storage errors.

Important: Custom Hooks Share Logic, Not State

Consider two components using the same Hook:

function ComponentA() {
  const [count, setCount] = useCounter();
}

function ComponentB() {
  const [count, setCount] = useCounter();
}
Enter fullscreen mode Exit fullscreen mode

Each component gets its own independent state.

Custom Hooks share the logic for managing state, not the actual state instance.

If components need to share the same state, use a shared parent, Context, or an appropriate state-management solution.


16. Other Useful React Hooks

The Hooks covered so far are enough for many everyday applications, but React provides additional Hooks for more specialized situations.

useLayoutEffect

useLayoutEffect is similar to useEffect, but it runs after DOM mutations and before the browser paints.

It is useful when you need to measure or synchronously adjust layout before the user sees the result.

import {
  useLayoutEffect,
  useRef,
  useState
} from "react";

function Tooltip() {
  const tooltipRef = useRef(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    if (tooltipRef.current) {
      setHeight(
        tooltipRef.current.getBoundingClientRect().height
      );
    }
  }, []);

  return (
    <div>
      <div ref={tooltipRef}>
        Tooltip content
      </div>

      <p>Tooltip height: {height}px</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use useLayoutEffect only when necessary. It can block browser painting, so useEffect is generally preferable for ordinary synchronization.

useImperativeHandle

useImperativeHandle allows a component to customize the value exposed through a ref.

It is useful when a parent needs access to a limited imperative API rather than the entire child implementation.

import {
  useImperativeHandle,
  useRef
} from "react";

function CustomInput({ ref }) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    focus() {
      inputRef.current.focus();
    },

    clear() {
      inputRef.current.value = "";
    }
  }), []);

  return <input ref={inputRef} />;
}
Enter fullscreen mode Exit fullscreen mode

This example uses the React 19 ref-as-prop API. In React 18 and earlier, forwardRef is generally used to receive a ref in a function component.

useSyncExternalStore

This Hook is designed for subscribing to external stores in a way that works correctly with React's rendering model.

It is especially useful for library authors and integrations with external state systems.

import { useSyncExternalStore } from "react";

function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);

  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

function NetworkStatus() {
  const isOnline = useSyncExternalStore(
    subscribe,
    getSnapshot
  );

  return (
    <p>
      {isOnline ? "Online" : "Offline"}
    </p>
  );
}
Enter fullscreen mode Exit fullscreen mode

For server-rendered applications, you may also need to provide a server snapshot function.


17. React 19: The use API Is Different from Traditional Hooks

React 19 introduced the use API, which can read resources such as Promises and Context.

Despite its name, use is not an ordinary Hook.

For example, unlike traditional Hooks, use can be called inside conditions and loops in supported situations.

import { use } from "react";

function Message({ messagePromise }) {
  const message = use(messagePromise);

  return <p>{message}</p>;
}
Enter fullscreen mode Exit fullscreen mode

When the Promise is pending, React can suspend rendering. The component should be placed within an appropriate Suspense boundary.

import { Suspense } from "react";

function App({ messagePromise }) {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Message messagePromise={messagePromise} />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

This API has specific rules and should not be confused with traditional Hooks such as useState and useEffect.


18. Practical Project: Building a Custom Counter Dashboard

Let's combine several Hooks into a small application.

Our dashboard will include:

  • Counter state with useReducer.
  • Theme management with useState.
  • A document title synchronization Effect.
  • A ref for focusing an input.
  • A memoized calculation.
  • A Custom Hook for reusable counter logic.

Step 1: Create the Custom Hook

import { useReducer } from "react";

const initialState = {
  count: 0
};

function counterReducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        count: state.count + 1
      };

    case "decrement":
      return {
        count: state.count - 1
      };

    case "reset":
      return initialState;

    default:
      return state;
  }
}

export function useCounter() {
  const [state, dispatch] = useReducer(
    counterReducer,
    initialState
  );

  return {
    count: state.count,
    increment: () => dispatch({ type: "increment" }),
    decrement: () => dispatch({ type: "decrement" }),
    reset: () => dispatch({ type: "reset" })
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Build the Dashboard

import {
  useEffect,
  useMemo,
  useRef,
  useState
} from "react";

import { useCounter } from "./useCounter";

function Dashboard() {
  const {
    count,
    increment,
    decrement,
    reset
  } = useCounter();

  const [theme, setTheme] = useState("light");
  const [name, setName] = useState("");

  const inputRef = useRef(null);

  const squaredCount = useMemo(() => {
    return count * count;
  }, [count]);

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

  function focusInput() {
    inputRef.current.focus();
  }

  return (
    <div
      style={{
        padding: "30px",
        background: theme === "dark" ? "#222" : "#fff",
        color: theme === "dark" ? "#fff" : "#222"
      }}
    >
      <h1>React Hooks Dashboard</h1>

      <h2>Counter: {count}</h2>

      <p>Squared Value: {squaredCount}</p>

      <button onClick={increment}>
        Increase
      </button>

      <button onClick={decrement}>
        Decrease
      </button>

      <button onClick={reset}>
        Reset
      </button>

      <hr />

      <h2>Theme Settings</h2>

      <button
        onClick={() =>
          setTheme(theme === "light" ? "dark" : "light")
        }
      >
        Toggle Theme
      </button>

      <hr />

      <h2>User Settings</h2>

      <input
        ref={inputRef}
        value={name}
        onChange={event => setName(event.target.value)}
        placeholder="Enter your name"
      />

      <button onClick={focusInput}>
        Focus Input
      </button>

      <p>
        Hello, {name || "Guest"}!
      </p>
    </div>
  );
}

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

This small project demonstrates how different Hooks can work together in a single component.

Each Hook has a specific responsibility:

Hook Responsibility
useReducer Counter state management
useState Theme and name state
useEffect Synchronizing the document title
useRef Accessing the input element
useMemo Caching a calculation
useCounter Reusing counter logic

Notice that useMemo isn't essential for this tiny calculation. It's included to demonstrate the API, not because squaring a number requires optimization.


19. Common React Hooks Mistakes

Let's review some mistakes developers frequently make.

Mistake 1: Calling Hooks Conditionally

Incorrect:

if (isLoggedIn) {
  const [user, setUser] = useState(null);
}
Enter fullscreen mode Exit fullscreen mode

Correct:

const [user, setUser] = useState(null);

if (!isLoggedIn) {
  return <p>Please log in.</p>;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Forgetting Effect Dependencies

Incorrect:

useEffect(() => {
  console.log(userId);
}, []);
Enter fullscreen mode Exit fullscreen mode

If userId changes, this Effect won't re-synchronize.

Correct:

useEffect(() => {
  console.log(userId);
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Mutating State Directly

Incorrect:

user.name = "John";
setUser(user);
Enter fullscreen mode Exit fullscreen mode

Correct:

setUser(previousUser => ({
  ...previousUser,
  name: "John"
}));
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Using useEffect for Derived State

Unnecessary:

const [total, setTotal] = useState(0);

useEffect(() => {
  setTotal(price * quantity);
}, [price, quantity]);
Enter fullscreen mode Exit fullscreen mode

Better:

const total = price * quantity;
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Using useRef Instead of State for UI

If a value must trigger a UI update, use state.

Changing ref.current doesn't cause React to render again.

Mistake 6: Overusing useMemo and useCallback

Not every value or function needs to be memoized.

Start with simple code. Optimize when you have a reason.

Mistake 7: Forgetting Effect Cleanup

If an Effect creates a timer or subscription, make sure it releases that resource when appropriate.

Mistake 8: Treating Custom Hooks as Shared State

Two components calling the same Custom Hook normally receive separate state instances.

Use Context or another shared state solution when the actual state must be shared.


20. Which Hook Should You Use?

Here's a practical reference table.

Situation Hook
Store component state useState
Manage complex state transitions useReducer
Synchronize with external systems useEffect
Access a DOM element useRef
Store mutable values without rendering useRef
Share values through a component tree useContext
Cache expensive calculations useMemo
Cache function references useCallback
Generate accessibility IDs useId
Mark updates as non-urgent useTransition
Defer a changing value useDeferredValue
Reuse stateful logic Custom Hooks
Measure layout before paint useLayoutEffect
Expose a custom imperative ref API useImperativeHandle
Subscribe to external stores useSyncExternalStore

21. Final Thoughts

React Hooks transformed how developers build React applications.

They made functional components powerful enough to manage state, synchronize with external systems, share logic, and handle complex application behavior.

The most important Hooks to master first are:

  1. useState
  2. useEffect
  3. useRef
  4. useContext
  5. useReducer
  6. Custom Hooks

Once you're comfortable with these, learning performance-related and specialized Hooks becomes much easier.

Don't try to use every Hook in every component.

Good React development is about choosing the right tool for the problem, keeping components understandable, and writing code that behaves correctly.

The best way to master Hooks is to build real applications and experiment with them.


What's Next?

In Part 4 of the React Mastery Series, we'll explore:

React Forms & API Integration — Building Real-World Interactive Applications

We'll cover:

  • Controlled and uncontrolled components.
  • Handling form submissions.
  • Form validation.
  • Managing complex forms.
  • Fetching data from APIs.
  • Loading and error states.
  • Async JavaScript in React.
  • Building a practical application that communicates with an API.

If you're following this series, keep practicing the examples and try building your own small project using multiple Hooks.

Happy coding! 🚀

Top comments (0)