DEV Community

Abrar Galib
Abrar Galib

Posted on

React Hooks Made Simple: The Complete Guide, Part-1

A Hook is a function that lets a component use a React feature. useState lets it remember a value. useEffect lets it do something after it shows up on the screen, like starting a timer. useContext lets it read shared data. Every Hook name starts with use, which makes them easy to spot.

Before Hooks, these features only worked inside class components, with constructors, this, and lifecycle methods like componentDidMount. Hooks give you the same abilities in a plain function, and the code comes out shorter and easier to follow.

Why Beginners Should Learn Hooks

Almost everything you'll read or write today uses them. The React docs, Next.js, and most libraries are built around function components, so classes are something you mostly see in old code. Hooks also remove a lot of boilerplate, since there are no classes, no constructors, and no binding methods. They make reuse easy too. If two components need the same behavior, you move it into a custom Hook once and use it in both. And the newest React features, like form Actions and optimistic updates, are built on Hooks, so learning them now means you're ready for those as well.

The Complete Guide

React Hooks changed how we write React apps. Before them, if a component needed state, or had to do something after it appeared on the screen, you had to write a class. Now a plain function can do all of that, and you can share the same logic between components without any tricks.

This guide covers all the stable React Hooks, from the basic ones to the newer ones that arrived with React 19. The examples use plain React and Next.js, and every Hook gets a short explanation plus code you can copy. If you're new, read it from the top. If you already know the basics, skip ahead to the section on new Hooks.

As of right now, the latest stable release is React 19.3, which dropped on September 9, 2026. This version finally makes ViewTransition and Fragment Refs stable. Even though neither of these is actually a Hook, I've covered both near the end of this guide since you'll definitely start seeing them pop up in newer codebases. Everything discussed below works perfectly fine on React 19, but if a specific Hook requires a newer minor version, I'll be sure to point it out.

The plan is simple. First the core Hooks, then the additional ones, then the advanced ones. After that come the Hooks added in React 19 and later, the Hooks that belong to Next.js, custom Hooks, common mistakes, and a cheatsheet you can keep open while you code.

Table of Contents

Core Hooks

useState

useState simply lets your component remember data. When you use it, you get the current value and a function to change it.

What is a Render?

Think of a render as React refreshing or redrawing the screen. It is just React running your component function to see what should change visually.

How it works:

  1. You call the function to update the data.
  2. This tells React: "Hey, the data changed!"
  3. React instantly renders (re-runs) the component to show the new data on your screen.
'use client';

import { useState } from 'react';

export default function LikeButton() {
  const [likes, setLikes] = useState(0);

  return (
    <button onClick={() => setLikes((current) => current + 1)}>
      Likes: {likes}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice how we use setLikes((current) => current + 1). When your new state depends on the old state, always pass a function like this. This ensures React always uses the absolute latest value, even if multiple updates happen at the exact same time.

Here are two important rules that beginners often miss:

  • Lazy Initialization (For heavy tasks): If setting up your starting value takes a lot of work (like reading from localStorage or filtering a huge list), don't run it directly inside useState. Instead, pass a function like useState(() => getHeavyData()). React will run this function only once on the very first render.
  • State Immutability (Never edit state directly): Never update an object or an array directly (like user.name = 'Alex'). React only checks if the overall object reference has changed. If you modify it directly, React won't notice the change and your screen won't update. Always make a fresh copy using the spread operator: setUser({ ...user, name: 'Alex' }).

Note: It's a good fit for counters, toggles, form inputs, and any small piece of UI state.

useEffect

useEffect is used for things that happen outside of the normal rendering process—like fetching data, starting timers, listening to the window, or using browser tools. React runs your effect right after the component shows up on the screen.

The list of brackets [] at the end is the dependency array. It simply tells React exactly when to run that code again.

What is Fetching?

Fetching just means asking a server or an external API for data over the internet (like getting a list of users, weather info, or product details) so you can show it on your app.

How it works:

  • Empty array []: Runs only once when the component first appears.
  • With values [props.id]: Runs again only when those specific values change.
'use client';

import { useEffect, useState } from 'react';

export default function WindowWidth() {
  const [width, setWidth] = useState(0);

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

    handleResize();
    window.addEventListener('resize', handleResize);

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

  return <p>Window width: {width}px</p>;
}
Enter fullscreen mode Exit fullscreen mode

Whatever you return inside useEffect is your cleanup function. React calls it right before running the effect again, and also when the component disappears from the screen.

Always clean up things like timers and event listeners. If you don't, they will keep running in the background and slow down your app (this is called a memory leak).

Why does my code run twice?

In development, React uses Strict Mode, which intentionally runs your effect twice. It does this just to test if your cleanup function is working properly. If you see your console.log appearing twice, that is exactly why!

Next.js detail: effects run only in the browser, never on the server. So the first HTML is built with your starting state, which is 0 here, and the effect fixes it once the page loads. A simple rule to follow is that if you can work something out while rendering, do it right there and skip the effect.

useContext

useContext reads shared data from the nearest provider above your component. It saves you from passing the same prop down through many layers, which people call prop drilling. Themes, the logged in user, and the language setting are the classic uses.

'use client';

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

const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext value={{ theme, setTheme }}>
      {children}
    </ThemeContext>
  );
}

export function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

React 19 lets you use the context itself as the provider, so <ThemeContext value={...}> does the job of <ThemeContext.Provider value={...}>. Also remember that every component reading the context renders again when the value changes, so keep fast changing data out of one big shared context.

In the Next.js App Router, put the provider in a file that starts with 'use client', then wrap children with it inside layout.js. The pages inside can still be Server Components.

Additional Hooks

useRef

Think of useRef as a little storage box. It holds onto a value between renders, but changing that value never triggers a re-render (it won't refresh the screen).

Developers use it for two main things:

  • Grabbing a DOM element: Like directly focusing an input box or scrolling to a specific part of the page.
  • Holding background data: Like storing a timer ID or tracking how many times a user clicked a button without needing to change what is shown on the screen.
'use client';

import { useRef } from 'react';

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

  return (
    <>
      <input ref={inputRef} placeholder="Search products" />
      <button onClick={() => inputRef.current.focus()}>Focus the box</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

In React 19 a function component can receive ref as a normal prop, so new code doesn't need forwardRef. Try not to read or write ref.current while rendering. Do it inside event handlers or effects.

useMemo

useMemo simply remembers the result of a heavy calculation so React doesn't have to do the math all over again on every render. It only recalculates when the data it depends on changes.

Think of it like this:

  • Use it for heavy work: Like filtering through thousands of users or sorting a massive list.
  • Skip it for small work: If you are just filtering 10 or 20 items, don't use it. The extra code overhead isn't worth it.
'use client';

import { useMemo, useState } from 'react';

export default function ProductSearch({ products }) {
  const [query, setQuery] = useState('');

  const matches = useMemo(() => {
    return products.filter((product) =>
      product.name.toLowerCase().includes(query.toLowerCase())
    );
  }, [products, query]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>{matches.length} products found</p>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

useCallback

useCallback does the exact same job as useMemo, but for functions instead of calculation results.

Normally, React creates your functions completely fresh on every single render. If you pass that function down to a child component, the child thinks it received a brand-new prop and updates itself, even if nothing actually changed.

useCallback stops this by remembering the function between renders.

Why it matters:

Think of an item list (like a shopping basket) with an Add Button:

  • Without useCallback, typing a single letter in the search box re-creates the "add" function. This forces the Add Button to re-render needlessly.
  • With useCallback, the "add" function stays the same in memory. Typing in the input refreshes the basket, but the Add Button stays completely untouched, skipping unnecessary work.
'use client';

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

const AddButton = memo(function AddButton({ onAdd }) {
  console.log('AddButton rendered');
  return <button onClick={onAdd}>Add one</button>;
});

export default function Basket() {
  const [count, setCount] = useState(0);
  const [note, setNote] = useState('');

  const handleAdd = useCallback(() => {
    setCount((current) => current + 1);
  }, []);

  return (
    <>
      <input value={note} onChange={(e) => setNote(e.target.value)} />
      <p>Items: {count}</p>
      <AddButton onAdd={handleAdd} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Don't add these two everywhere. They clutter the code, and measuring first saves you from fixing problems you don't have. Also, React Compiler has reached version 1.0 and is no longer experimental, and when you switch it on it adds this kind of memoization for you at build time. In newer projects you'll write useMemo and useCallback much less than older tutorials suggest.

useReducer

useReducer is great when you have a component with complex state logic, or when multiple pieces of state need to update together at the same time. Instead of having many scattered useState calls, it keeps all your state logic in one single place.

Here is how it works in two simple steps:

  1. The Action: You tell React what happened by sending an action with a clear label, like "added", "deleted", or "cleared".
  2. The Reducer: A special function (the reducer) looks at that label and handles the math to decide exactly what the new state should be.
'use client';

import { useReducer } from 'react';

function cartReducer(state, action) {
  switch (action.type) {
    case 'added':
      return [...state, action.item];
    case 'removed':
      return state.filter((item) => item.id !== action.id);
    case 'cleared':
      return [];
    default:
      throw new Error('Unknown action: ' + action.type);
  }
}

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

  return (
    <div>
      <button
        onClick={() =>
          dispatch({ type: 'added', item: { id: Date.now(), name: 'Book' } })
        }
      >
        Add book
      </button>
      <button onClick={() => dispatch({ type: 'cleared' })}>Clear</button>
      <p>{items.length} items in cart</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The reducer has to be a pure function. The same input always gives the same output, and it never fetches data or makes random values. That also makes it easy to test without rendering anything.

Advanced Hooks

useLayoutEffect

It looks exactly like useEffect, but runs earlier: right after React updates the DOM structure, but before the browser draws anything on the screen.

  • When to use: Use it only when you need to measure an element (like its height or width) and immediately change the UI based on that measurement. Using a normal useEffect here might cause a quick, annoying layout flicker.
  • Warning: It blocks the browser from drawing, so keep the code inside small. For everything else, stick to useEffect.
'use client';

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

export default function MeasuredBox({ children }) {
  const boxRef = useRef(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    // Measures the exact height before the screen draws
    setHeight(boxRef.current.getBoundingClientRect().height);
  }, []);

  return (
    <div ref={boxRef}>
      {children}
      <small>Box height: {height}px</small>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useImperativeHandle

Normally, parents talk to child components via props. But sometimes, a parent needs to trigger a method inside the child directly (like calling .focus() or .clear() on a custom input). useImperativeHandle lets the child component expose specific functions to the parent through a ref.

  • Note: In React 19, ref is a normal prop, so you don't need forwardRef anymore.
'use client';

import { useImperativeHandle, useRef } from 'react';

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

  // Decide exactly what methods the parent can call
  useImperativeHandle(ref, () => ({
    focus() { inputRef.current.focus(); },
    clear() { inputRef.current.value = ''; }
  }));

  return <input ref={inputRef} />;
}

export default function Form() {
  const fancyRef = useRef(null);

  return (
    <>
      <FancyInput ref={fancyRef} />
      <button onClick={() => fancyRef.current.focus()}>Focus</button>
      <button onClick={() => fancyRef.current.clear()}>Clear</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

useId

This hook generates a unique, stable ID. It is essential for linking form fields with labels (htmlFor={id}).

  • Why use it? If you generate IDs with Math.random(), the server-rendered HTML and browser-rendered HTML won't match, causing Next.js to crash with a hydration mismatch error (Hydration is when React attaches itself to the server's HTML). useId keeps the ID identical on both sides.
  • Rule: Never use it to generate keys for item lists.
'use client';

import { useId } from 'react';

export default function EmailField() {
  const id = useId();

  return (
    <>
      <label htmlFor={id}>Email</label>
      <input id={id} type="email" />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)