Article

10 React Hooks Explained with Real-World Examples: 2026 Guide

10 React Hooks Explained with Real-World Examples: 2026 Guide

React Hooks look simple at first. You call useState , update a value, and the screen changes. Then the real questions begin: Why did my effect run twice? Why does this callback see an old value? Should I use useMemo everywhere for performance? When is useReducer better than useSt

11 min readLanguage: EN EnglishFree0 claps0 comments
Engineering ArticlesReactReact.jsReact HooksuseStateuseEffectuseContextuseReduceruseRefuseMemouseCallbackuseTransitionuseDeferredValueuseOptimistic
Reading options

10 React Hooks Explained with Real-World Examples: 2026 Guide

React Hooks look simple at first. You call useState, update a value, and the screen changes.

Then the real questions begin:

  • Why did my Effect run twice?

  • Why does my callback see an old value?

  • Should I use useMemo everywhere?

  • When is useReducer better than useState?

  • How can I update the interface before the server responds?

The difficult part is not memorizing Hook syntax. It is understanding which problem each Hook solves.

This guide explains ten important React Hooks using practical examples instead of isolated demonstrations.

The two rules of React Hooks

Before using any Hook, remember these two rules:

  1. Call Hooks only at the top level of a component or custom Hook.

  2. Call Hooks only from React components or custom Hooks.

Do not call Hooks inside conditions, loops, event handlers, or ordinary JavaScript functions.

React relies on Hooks being called in the same order during every render.

1. useState: Store information that changes the interface

useState gives a component memory.

Use it when a value can change and that change should update what the user sees.

Imagine an e-commerce page where a customer selects the product quantity:

import { useState } from "react";

export default function QuantitySelector() {
  const [quantity, setQuantity] = useState(1);

  function increase() {
    setQuantity(current => current + 1);
  }

  function decrease() {
    setQuantity(current => Math.max(1, current - 1));
  }

  return (
    <div>
      <button onClick={decrease}>-</button>
      <span>{quantity}</span>
      <button onClick={increase}>+</button>
    </div>
  );
}

The function form is important:

setQuantity(current => current + 1);

It should be used when the next state depends on the previous state. It prevents problems when React batches multiple state updates.

Common mistake: Mutating state

React state should be treated as read-only.

// Wrong: Modifies the existing object
user.name = "Alaa";
setUser(user);

Create a new object instead:

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

Simple rule: If changing the value should update the screen, it probably belongs in state.

2. useEffect: Synchronize with something outside React

useEffect is often described as code that runs after rendering.

A more useful definition is:

Use an Effect to synchronize your component with something outside React.

Examples include:


  • API requests


  • Timers


  • Browser events


  • WebSocket connections


  • Third-party libraries


  • Analytics services

Here is a product component that loads data from an API:

import { useEffect, useState } from "react";

export default function ProductDetails({ productId }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);

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

    async function loadProduct() {
      setLoading(true);

      try {
        const response = await fetch(
          `/api/products/${productId}`,
          {
            signal: controller.signal,
          }
        );

        if (!response.ok) {
          throw new Error("Could not load the product");
        }

        const data = await response.json();
        setProduct(data);
      } catch (error) {
        if (error.name !== "AbortError") {
          console.error(error);
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }

    loadProduct();

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

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

  if (!product) {
    return <p>Product not found.</p>;
  }

  return <h1>{product.name}</h1>;
}

When productId changes, React cancels the previous request and starts a new one.

In development, React Strict Mode may run an extra setup and cleanup cycle. This helps discover missing cleanup logic. It does not mean every Effect runs twice in production.

Common mistake: Using an Effect for calculated values

This Effect is unnecessary:

const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

The value can be calculated directly:

const fullName = `${firstName} ${lastName}`;

Simple rule: If you are not synchronizing with an external system, you may not need an Effect.

3. useContext: Share values without prop drilling

Prop drilling happens when data passes through several components that do not need it just so a deeply nested component can receive it.

useContext allows a component to read a shared value from the nearest matching provider.

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

const CurrencyContext = createContext(null);

export function StoreProvider({ children }) {
  const [currency, setCurrency] = useState("USD");

  return (
    <CurrencyContext.Provider
      value={{ currency, setCurrency }}
    >
      {children}
    </CurrencyContext.Provider>
  );
}

export function ProductPrice({ amount }) {
  const context = useContext(CurrencyContext);

  if (!context) {
    throw new Error(
      "ProductPrice must be used inside StoreProvider"
    );
  }

  const { currency } = context;

  return (
    <span>
      {new Intl.NumberFormat("en", {
        style: "currency",
        currency,
      }).format(amount)}
    </span>
  );
}

Context is useful for shared information such as:

  • The authenticated user

  • Theme

  • Language

  • Currency

  • Permissions

  • Feature flags

Common mistake: Putting everything in one Context

When a Context value changes, components consuming that Context may render again.

A single large Context containing unrelated information can create unnecessary rendering and tight coupling.

Simple rule: Use Context for information needed by many components at different levels. Do not use it as an automatic replacement for props or proper state management.

4. useReducer: Manage complex state changes

useState works well for independent values.

When several values change together according to specific rules, useReducer can make the logic easier to understand and test.

Here is a simple shopping cart reducer:

import { useReducer } from "react";

function cartReducer(state, action) {
  switch (action.type) {
    case "product_added": {
      const existingProduct = state.find(
        item => item.id === action.product.id
      );

      if (existingProduct) {
        return state.map(item =>
          item.id === action.product.id
            ? {
                ...item,
                quantity: item.quantity + 1,
              }
            : item
        );
      }

      return [
        ...state,
        {
          ...action.product,
          quantity: 1,
        },
      ];
    }

    case "product_removed":
      return state.filter(
        item => item.id !== action.productId
      );

    case "cart_cleared":
      return [];

    default:
      throw new Error(
        `Unknown action: ${action.type}`
      );
  }
}

export default function Cart() {
  const [items, dispatch] = useReducer(
    cartReducer,
    []
  );

  function addKeyboard() {
    dispatch({
      type: "product_added",
      product: {
        id: 7,
        name: "Keyboard",
        price: 80,
      },
    });
  }

  return (
    <div>
      <button onClick={addKeyboard}>
        Add keyboard
      </button>

      <button
        onClick={() =>
          dispatch({ type: "cart_cleared" })
        }
      >
        Clear cart
      </button>

      <p>{items.length} different products</p>
    </div>
  );
}

The component describes what happened, while the reducer determines how state changes.

Consider useReducer when:

  • State contains several related fields.

  • Multiple event handlers update the same state.

  • State transitions include business rules.

  • You want transitions represented by clear actions.

Simple rule: Use useState for simple values. Consider useReducer when the state transitions become difficult to follow.

5. useRef: Remember a value without rendering

useRef returns the same mutable object during every render.

Changing its current property does not cause the component to render again.

A common use is accessing a DOM element:

import { useRef } from "react";

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

  function focusSearch() {
    inputRef.current?.focus();
  }

  return (
    <div>
      <input
        ref={inputRef}
        placeholder="Search products"
      />

      <button onClick={focusSearch}>
        Focus search
      </button>
    </div>
  );
}

A ref can also store:

  • A timer ID

  • A previous value

  • An external library instance

  • Information needed by event handlers

  • A value that must survive renders

For example:

const timeoutRef = useRef(null);

function scheduleSearch(query) {
  clearTimeout(timeoutRef.current);

  timeoutRef.current = setTimeout(() => {
    searchProducts(query);
  }, 400);
}

useRef versus useState

Changing state causes React to render the component again.

Changing a ref does not cause another render.

Simple rule: If the value affects what the user sees, use state. If it is only needed by your code between renders, consider using a ref.

6. useMemo: Cache an expensive calculation

useMemo caches the result of a calculation until one of its dependencies changes.

Imagine a dashboard that filters and sorts thousands of orders:

import { useMemo } from "react";

export default function Orders({
  orders,
  search,
  sortBy,
}) {
  const visibleOrders = useMemo(() => {
    const normalizedSearch =
      search.toLowerCase();

    return orders
      .filter(order =>
        order.customerName
          .toLowerCase()
          .includes(normalizedSearch)
      )
      .toSorted((first, second) => {
        if (sortBy === "total") {
          return second.total - first.total;
        }

        return (
          new Date(second.createdAt) -
          new Date(first.createdAt)
        );
      });
  }, [orders, search, sortBy]);

  return visibleOrders.map(order => (
    <div key={order.id}>
      {order.customerName}
    </div>
  ));
}

React recalculates visibleOrders only when orders, search, or sortBy changes.

Common mistake: Memoizing every calculation

This normally does not need useMemo:

const fullName = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
);

The direct version is clearer:

const fullName = `${firstName} ${lastName}`;

Memoization has its own cost and adds complexity.

Use it when:

  • A calculation is noticeably expensive.

  • Profiling reveals a performance problem.

  • Stable object identity is required by another optimization.

Simple rule: useMemo is a performance optimization, not something your code needs for correctness.

7. useCallback: Cache a function reference

A component normally creates new function objects every time it renders. Usually, this is not a problem.

useCallback is useful when the function’s identity matters.

One example is passing a callback to a component wrapped with React.memo.

import {
  memo,
  useCallback,
  useState,
} from "react";

const ProductRow = memo(function ProductRow({
  product,
  onAdd,
}) {
  console.log("Rendering", product.name);

  return (
    <button onClick={() => onAdd(product.id)}>
      Add {product.name}
    </button>
  );
});

export default function ProductList({
  products,
}) {
  const [cart, setCart] = useState([]);

  const addToCart = useCallback(productId => {
    setCart(current => [
      ...current,
      productId,
    ]);
  }, []);

  return products.map(product => (
    <ProductRow
      key={product.id}
      product={product}
      onAdd={addToCart}
    />
  ));
}

Because addToCart keeps the same function reference, ProductRow can skip unnecessary renders when its other properties have not changed.

useMemo versus useCallback

  • useMemo caches a calculated value.

  • useCallback caches a function reference.

Simple rule: Do not wrap every function in useCallback. Use it when stable function identity solves an actual rendering or dependency problem.

8. useTransition: Keep important interactions responsive

Some updates are urgent.

For example, an input should update immediately while the user is typing. Rendering a large result list is less urgent.

useTransition allows you to mark an update as non-urgent.

import {
  useState,
  useTransition,
} from "react";

export default function ProductSearch() {
  const [input, setInput] = useState("");
  const [query, setQuery] = useState("");

  const [isPending, startTransition] =
    useTransition();

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

    setInput(value);

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

  return (
    <div>
      <input
        value={input}
        onChange={handleChange}
      />

      {isPending && (
        <small>Updating results...</small>
      )}

      <LargeProductList query={query} />
    </div>
  );
}

Updating the input is urgent, so setInput runs normally.

Updating the large result list is less urgent, so setQuery runs inside a transition.

A transition does not use a timer, and it does not make JavaScript run on another thread.

It tells React that the update can be interrupted if something more important happens.

Simple rule: Use a transition when a slow render makes an important interaction feel blocked.

9. useDeferredValue: Allow part of the interface to lag behind

useDeferredValue provides a deferred version of a value.

It is useful when a value changes quickly, but an expensive part of the interface does not need to update immediately.

import {
  useDeferredValue,
  useState,
} from "react";

export default function CustomerSearch({
  customers,
}) {
  const [query, setQuery] = useState("");

  const deferredQuery =
    useDeferredValue(query);

  const isStale = query !== deferredQuery;

  const results = customers.filter(customer =>
    customer.name
      .toLowerCase()
      .includes(deferredQuery.toLowerCase())
  );

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

      <div
        style={{
          opacity: isStale ? 0.6 : 1,
        }}
      >
        {results.map(customer => (
          <p key={customer.id}>
            {customer.name}
          </p>
        ))}
      </div>
    </div>
  );
}

The input shows the newest value immediately. The customer list may temporarily use the previous value while React prepares the new result.

This is not the same as debouncing.

Debouncing waits for a specific amount of time. A deferred value allows React to schedule the update based on rendering priority.

Simple rule: Use useDeferredValue when a rapidly changing value controls a slower part of the interface.

10. useOptimistic: Update the interface before the server responds

Users should not always have to wait for a network request before seeing the expected result.

useOptimistic lets the interface temporarily show the expected result while an operation is being processed.

Here is an optimistic comment form:

import { useOptimistic } from "react";

export default function Comments({
  comments,
  saveComment,
}) {
  const [
    optimisticComments,
    addOptimisticComment,
  ] = useOptimistic(
    comments,
    (currentComments, newComment) => [
      ...currentComments,
      {
        ...newComment,
        pending: true,
      },
    ]
  );

  async function submitComment(formData) {
    const text = formData.get("comment");

    const temporaryComment = {
      id: crypto.randomUUID(),
      text,
    };

    addOptimisticComment(
      temporaryComment
    );

    await saveComment(text);
  }

  return (
    <div>
      <form action={submitComment}>
        <input name="comment" required />
        <button type="submit">Post</button>
      </form>

      {optimisticComments.map(comment => (
        <p
          key={comment.id}
          style={{
            opacity: comment.pending
              ? 0.5
              : 1,
          }}
        >
          {comment.text}

          {comment.pending &&
            " (Sending...)"}
        </p>
      ))}
    </div>
  );
}

The new comment appears immediately with reduced opacity.

When the server operation finishes, the real data replaces the temporary optimistic state.

Optimistic updates are suitable for operations likely to succeed, such as:

  • Liking a post

  • Adding a comment

  • Updating a preference

  • Adding an item to a list

The application should still display an appropriate error if the operation fails.

Simple rule: Use optimistic updates to make reliable actions feel immediate, while keeping the server as the final source of truth.

Quick comparison of the ten Hooks

  • useState: Stores values that affect the interface.

  • useEffect:

Featured Articles

Kafka in Production: Partition Strategy, Consumer Lag, Reliability, and an Incident Playbook
EditorialEN
17 minFree

Kafka in Production: Partition Strategy, Consumer Lag, Reliability, and an Incident Playbook

A production Kafka cluster needs more than brokers. Learn to choose partition keys, plan retention and capacity, monitor lag, handle rebalances, and rehearse failure recovery.

Engineering ArticlesPlatform Guides
0 claps
Read
Laravel and Kafka Without Lost Events: The Transactional Outbox, Idempotent Consumers, and PostgreSQL
EditorialEN
17 minFree

Laravel and Kafka Without Lost Events: The Transactional Outbox, Idempotent Consumers, and PostgreSQL

A database commit and a Kafka publish cannot safely be treated as one ordinary transaction. This guide builds an outbox and consumer design that survives crashes and duplicate delivery.

Engineering ArticlesPlatform Guides
0 claps
Read
Apache Kafka Explained: Topics, Partitions, Consumer Groups, and Your First Event Pipeline
EditorialEN
15 minFree

Apache Kafka Explained: Topics, Partitions, Consumer Groups, and Your First Event Pipeline

Follow one order event from producer to consumers, then run a local Kafka topic and learn what partitions, offsets, keys, and consumer groups actually do.

Engineering ArticlesPlatform Guides
0 claps
Read

Comments

0 comments

No approved comments are visible yet. New community replies may wait for moderation.