DEV Community

Abrar Galib
Abrar Galib

Posted on

Welcome Back, React Hooks Made Simple: The Complete Guide, Part-2

In [Part 1] https://dev.to/abrar_galib_5c0cf41ad3a3e/react-hooks-made-simple-the-complete-guide-part-1-2jln we covered the core Hooks (useState, useEffect, useContext), the additional Hooks (useRef, useMemo, useCallback, useReducer) and the advanced ones (useLayoutEffect, useImperativeHandle, useId). If you haven't read it yet, start there, because everything in this part builds on it.

In Part 2:

  • Two Hooks that keep your app smooth when an update is heavy
  • A few more advanced Hooks for outside data and library authors
  • The new Hooks that came with 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

Everything below works on React 19. A few things need a newer minor version, and I point them out as we go: useEffectEvent needs React 19.2, and ViewTransition, Fragment Refs and use(browser()) need React 19.3, which is the current stable release.

Table of Contents

Performance Hooks

Sometimes an update is heavy: a huge list, a slow tab, a big search result. If React does all the work at once, the page freezes. Typing feels laggy and buttons stop responding.

These two Hooks let you tell React: "this update is not urgent, so do the important things first."

What is an urgent update?
Anything the user expects to see instantly, like typing in a box or clicking a button. Showing the search results or opening a heavy tab can wait a moment. React can handle the urgent update first and finish the slow one in the background.

useTransition

useTransition lets you mark a state update as low priority. It gives you two things: isPending, which is true while the slow update is happening, and startTransition, a function you wrap around the state update.

'use client';

import { useState, useTransition } from 'react';

function About() {
  return <p>Hi, I write about React.</p>;
}

function SlowPosts() {
  // Pretend this tab is heavy: 300 rows, and each row takes a little time
  const rows = [];
  for (let i = 0; i < 300; i++) {
    rows.push(<SlowRow key={i} number={i + 1} />);
  }
  return <ul>{rows}</ul>;
}

function SlowRow({ number }) {
  const start = performance.now();
  while (performance.now() - start < 2) {
    // do nothing for 2 ms on purpose
  }
  return <li>Post #{number}</li>;
}

export default function TabContainer() {
  const [tab, setTab] = useState('about');
  const [isPending, startTransition] = useTransition();

  function selectTab(nextTab) {
    // This update can wait, so the page stays clickable
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <button onClick={() => selectTab('about')}>About</button>
      <button onClick={() => selectTab('posts')}>Posts</button>
      {isPending && <p>Loading...</p>}
      {tab === 'about' ? <About /> : <SlowPosts />}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Click "Posts" and the old tab stays on screen while React prepares the new one in the background. You can click "About" again at any time and React will drop the slow work and switch back. Without startTransition, the whole page would freeze for a moment.

Note: In React 19 you can even pass an async function to startTransition, and isPending stays true until it finishes. This is the base for the form Hooks later in this guide.

useDeferredValue

useDeferredValue gives you a copy of a value that intentionally "lags behind". While the user keeps typing, your input stays fast, and the heavy part of the screen catches up a moment later.

Use it when you can't wrap the state setter yourself, for example when the value arrives as a prop.

'use client';

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

const Results = memo(function Results({ items, query }) {
  const matches = items.filter((item) =>
    item.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <ul>
      {matches.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
});

export default function Search({ items }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <Results items={items} query={deferredQuery} />
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two things to remember:

  • You must wrap the slow component in memo. If you don't, it re-renders on every keystroke anyway and you save nothing.
  • query !== deferredQuery tells you the list is still catching up. Here we use it to dim the old results.

React 19 also lets you pass a starting value as the second argument, like useDeferredValue(query, '').

useTransition or useDeferredValue?

useTransition useDeferredValue
What it wraps The code that sets state The value itself
Use it when You own the state setter The value comes from a prop or a Hook
You get isPending flag A lagging copy of the value

Note: Don't wrap the setState of a text input in startTransition. The text in the box must update instantly, so the input state has to stay urgent. Defer the slow part instead, like we did above.

More Advanced Hooks

You will use these three much less often than the ones above. Still, you should know what they are, because you'll see them in libraries and in other people's code.

useSyncExternalStore

useSyncExternalStore connects React to data that lives outside of React and changes on its own, like the browser's online status, the window size, or a global store.

What is an external store?
It's any data source that React doesn't own. React can't know when it changes, so you have to tell React how to listen to it. Libraries like Redux and Zustand use this Hook under the hood.

It takes three functions:

  • subscribe: starts listening for changes and returns a cleanup function
  • getSnapshot: reads the current value
  • getServerSnapshot: gives a starting value for server rendering, so the server HTML matches the browser HTML during hydration
'use client';

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 useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine, // getSnapshot: read the browser value
    () => true              // getServerSnapshot: what the server should assume
  );
}

export default function StatusBar() {
  const isOnline = useOnlineStatus();
  return <p>{isOnline ? 'Online' : 'Offline'}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Why not just use useEffect and useState? Because this Hook also prevents a bug called tearing, where two parts of the screen show different values of the same data during a render.

Two rules to remember:

  • Define subscribe outside your component. If it's created inside, React will unsubscribe and subscribe again on every render.
  • getSnapshot must return the same value when nothing changed. If it returns a brand-new object every time, you will get an infinite loop.

In Next.js, getServerSnapshot is required if the component renders on the server.

useInsertionEffect

This one is built for people who write CSS-in-JS libraries. It runs before React changes the DOM, so a library can safely inject <style> tags at the right moment. If you build normal apps, you will probably never need it.

Here is the order in which the three effect Hooks run:

  1. useInsertionEffect runs before React updates the DOM
  2. useLayoutEffect runs after the DOM updates, before the browser paints
  3. useEffect runs after the browser paints
'use client';

import { useInsertionEffect } from 'react';

export default function Highlight({ children }) {
  useInsertionEffect(() => {
    const style = document.createElement('style');
    style.textContent = '.highlight { background: yellow; }';
    document.head.appendChild(style);

    return () => document.head.removeChild(style);
  }, []);

  return <mark className="highlight">{children}</mark>;
}
Enter fullscreen mode Exit fullscreen mode

Libraries like styled-components and Emotion already do this for you, so just use them.

useDebugValue

A tiny Hook that adds a label next to your custom Hook inside React DevTools. It only helps with debugging, and it only works inside custom Hooks.

import { useDebugValue, useSyncExternalStore } from 'react';

// "subscribe" is the same function from the previous example
function useOnlineStatus() {
  const isOnline = useSyncExternalStore(
    subscribe,
    () => navigator.onLine,
    () => true
  );

  useDebugValue(isOnline ? 'Online' : 'Offline');
  return isOnline;
}
Enter fullscreen mode Exit fullscreen mode

Open DevTools, find the component that uses useOnlineStatus, and you will see the Online or Offline label right next to it.

New Hooks in React 19 and Later

React 19 was built around one idea: Actions. A few new Hooks make Actions easy to use, especially with forms. Then React 19.2 and 19.3 added a few more useful things on top.

What is an Action?
An Action is a function that changes something, usually by talking to a server. Submitting a form is the classic example. React runs it inside a transition, so it can track a pending state for you, and it updates the screen when the work is done.

useActionState

useActionState runs a function when a form is submitted and keeps track of the result. You get the latest result, a function to put on your form, and a isPending flag.

'use client';

import { useActionState } from 'react';

async function subscribe(previousState, formData) {
  const email = formData.get('email');

  // Pretend we are talking to a server
  await new Promise((resolve) => setTimeout(resolve, 1000));

  if (!email.includes('@')) {
    return { message: 'Please enter a valid email.' };
  }
  return { message: `Thanks! We will write to ${email}.` };
}

export default function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(subscribe, {
    message: '',
  });

  return (
    <form action={formAction}>
      <input name="email" type="text" placeholder="you@example.com" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Subscribe'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. You pass in your function and a starting state.
  2. You put formAction on the form's action prop.
  3. When the form is submitted, React calls your function with the previous state and the form data.
  4. Whatever your function returns becomes the new state.

Notice that we don't need onSubmit, preventDefault or a useState for every input. React reads the form data for us.

Note: After the action finishes, React resets the form's fields. That's normal behavior in React 19.

In Next.js you can pass a Server Action instead. Put it in its own file with 'use server' at the top, then import it:

// app/actions.js
'use server';

export async function subscribe(previousState, formData) {
  // save to your database here
  return { message: 'Thanks for subscribing!' };
}
Enter fullscreen mode Exit fullscreen mode

You might see the old name useFormState in older tutorials. It was renamed to useActionState.

useFormStatus

useFormStatus tells a component whether the form it sits inside is currently submitting. It's perfect for a submit button. You import it from react-dom, not from react.

'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : 'Save'}
    </button>
  );
}

async function saveProfile(formData) {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log('Saved:', formData.get('name'));
}

export default function ProfileForm() {
  return (
    <form action={saveProfile}>
      <input name="name" placeholder="Your name" />
      <SubmitButton />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: useFormStatus only works in a component that is rendered inside a <form>. If you call it in the same component that renders the <form>, pending will always be false. That's why the button is its own component here. The nice part is that you can reuse SubmitButton in every form in your app.

useOptimistic

What is Optimistic UI?
When you like a post on a social app, the heart turns red right away. It doesn't wait for the server. The app is being optimistic: it assumes the server will say yes, shows the result immediately, and fixes things later if something goes wrong.

useOptimistic gives you a temporary value that lasts only while an Action is running. When the Action finishes, React switches back to the real state.

'use client';

import { useOptimistic, useState, useTransition } from 'react';

async function saveLike() {
  // Pretend this is a slow server call
  await new Promise((resolve) => setTimeout(resolve, 1500));
}

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

  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (currentLikes, amount) => currentLikes + amount
  );

  function handleClick() {
    startTransition(async () => {
      addOptimisticLike(1);                // the screen updates instantly
      await saveLike();                    // the real work takes 1.5 seconds
      setLikes((current) => current + 1);  // now update the real state
    });
  }

  return <button onClick={handleClick}>Likes: {optimisticLikes}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Click the button and the number goes up right away, even though the "server" takes 1.5 seconds. Once the Action is done, React drops the temporary value and shows the real one, which is the same number, so nothing jumps. If the save fails, the temporary value disappears and the real number is shown again.

The setter (addOptimisticLike) must be called inside an Action, like inside startTransition or a form action. If you call it anywhere else, React will show you a warning.

Note: useOptimistic is a React Hook. It's not specific to Next.js, even though it works great with Server Actions.

use

use lets you read a resource while rendering. It works with two things: a promise and a context.

It looks like a Hook, but it has a special power: you can call it inside if statements and loops, and after an early return. Normal Hooks can't do that. The one place you can't use it is inside try / catch.

Reading a promise

The best pattern is to create the promise in a Server Component, pass it down, and wrap the client component in Suspense.

// app/page.js (a Server Component)
import { Suspense } from 'react';
import Comments from './comments';

export default function Page() {
  const commentsPromise = fetch(
    'https://jsonplaceholder.typicode.com/comments?_limit=5'
  ).then((res) => res.json());

  return (
    <Suspense fallback={<p>Loading comments...</p>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/comments.js
'use client';

import { use } from 'react';

export default function Comments({ commentsPromise }) {
  const comments = use(commentsPromise); // waits here until the data arrives

  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.name}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

While the promise is loading, React shows the Suspense fallback. When it's ready, the component renders with the data. There is no useEffect, no loading state and no isLoading flag.

Note: Don't create a new promise inside a Client Component while rendering, and then pass it to use. It would create a fresh promise on every render. Create it in a Server Component, or use a library that caches it.

Reading context, even after an if

import { use } from 'react';
import { ThemeContext } from './theme-context';

function Heading({ children }) {
  if (children == null) {
    return null;
  }

  // useContext can't be called here, but use can
  const theme = use(ThemeContext);
  return <h1 className={theme}>{children}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

New in React 19.3: use(browser())

Some components can't make sensible HTML on the server, for example because they read the visitor's time zone. Calling use(browser()) tells React to skip this component during server rendering and show the nearest Suspense fallback instead. Once the page loads in the browser, it renders normally.

import { Suspense, use } from 'react';
import { browser } from 'react-dom';

function TimeZone() {
  use(browser());
  const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;

  return <p>{timeZone}</p>;
}

export default function App() {
  return (
    <Suspense fallback="Loading...">
      <TimeZone />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Before 19.3, people did this with a mounted state and a useEffect. Now there is a proper way.

Note: In recent Next.js versions, the params prop of a page is a promise. In a Client Component page you read it with use:

'use client';

import { use } from 'react';

export default function Page({ params }) {
  const { slug } = use(params);
  return <h1>{slug}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

useEffectEvent

Needs React 19.2 or newer.

Here is a problem you will meet sooner or later. Your effect needs to read a value, but you don't want the effect to re-run when that value changes.

Imagine we log a page visit, and we also want to log how many items are in the cart:

'use client';

import { useEffect, useEffectEvent } from 'react';

function logVisit(url, numberOfItems) {
  console.log(`Visited ${url} with ${numberOfItems} items in the cart`);
}

export default function Page({ url, numberOfItems }) {
  // This function always sees the latest numberOfItems
  const onVisit = useEffectEvent((visitedUrl) => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]); // only url here. No numberOfItems, no onVisit.

  return <h1>Page: {url}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

If we had put numberOfItems in the dependency array, the visit would be logged again every time the cart changes, which is wrong. If we left it out without useEffectEvent, the effect would read an old value. useEffectEvent solves both problems.

Rules to remember:

  • Only call an Effect Event from inside an effect.
  • Don't pass it to other components or Hooks.
  • Never put it in the dependency array.

Note: You might see useEvent in older blog posts. That was the early experimental name, and it never shipped in stable React. The real name is useEffectEvent.

Bonus: ViewTransition and Fragment Refs

React 19.3 made two more features stable. Neither of them is a Hook, but you will start seeing them in newer codebases, so here is a quick look.

ViewTransition

<ViewTransition> animates elements when they enter, exit or change, using the browser's View Transition API. By default you get a smooth cross-fade.

'use client';

import { ViewTransition, useState, startTransition } from 'react';

export default function Toggle() {
  const [show, setShow] = useState(false);

  return (
    <>
      <button
        onClick={() => {
          startTransition(() => {
            setShow((prev) => !prev);
          });
        }}
      >
        {show ? 'Hide' : 'Show'} card
      </button>

      {show && (
        <ViewTransition>
          <div className="card">Hello! I fade in and out.</div>
        </ViewTransition>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

React picks the animation based on what happened: enter (added), exit (removed), update (children changed) or share (a named one moved from one place to another).

The important rule: only updates marked as a Transition animate. That means state updates inside startTransition, a Suspense reveal, or an update from useDeferredValue. That's why we learned useTransition first. It currently works in the DOM only, not in React Native.

Fragment Refs

Before, you needed a wrapper <div> just to attach a ref to a group of elements. Now you can pass a ref straight to a <Fragment> and work with its DOM children as a group.

'use client';

import { Fragment, useRef } from 'react';

export default function Links() {
  const fragmentRef = useRef(null);

  return (
    <>
      <button onClick={() => fragmentRef.current.focus()}>
        Focus the first link
      </button>

      <Fragment ref={fragmentRef}>
        <a href="#one">One</a>
        <a href="#two">Two</a>
      </Fragment>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The ref gives you a FragmentInstance with methods for events (addEventListener), focus (focus, focusLast, blur), observers (observeUsing) and scrolling (scrollIntoView). You must write <Fragment> in full. The short <> syntax can't take a ref.

Next.js Hooks

These Hooks don't come from React. They come from Next.js, and they work in the App Router (the app folder). Because they read browser and route information, they only work in Client Components, so put 'use client' at the top of the file.

Note: If you see import { useRouter } from 'next/router', that's the old Pages Router. In the App Router, always import from next/navigation.

useRouter

useRouter lets you change pages from your code, for example after a login. For normal links, use the <Link> component instead.

'use client';

import { useRouter } from 'next/navigation';

export default function LoginButton() {
  const router = useRouter();

  async function handleLogin() {
    // ...log the user in...
    router.push('/dashboard');
  }

  return <button onClick={handleLogin}>Log in</button>;
}
Enter fullscreen mode Exit fullscreen mode

The router object has these methods:

  • router.push('/path') goes to a new page and adds it to the history
  • router.replace('/path') goes to a new page without adding to the history
  • router.back() and router.forward() move through the history
  • router.refresh() reloads the current route's data
  • router.prefetch('/path') loads a page in the background before the user clicks

usePathname

usePathname returns the current URL path, like /blog/my-post. A very common use is highlighting the active link in a navbar.

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';

const links = [
  { href: '/', label: 'Home' },
  { href: '/blog', label: 'Blog' },
];

export default function NavBar() {
  const pathname = usePathname();

  return (
    <nav>
      {links.map((link) => (
        <Link
          key={link.href}
          href={link.href}
          style={{ fontWeight: pathname === link.href ? 'bold' : 'normal' }}
        >
          {link.label}
        </Link>
      ))}
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

useSearchParams

useSearchParams reads the query string, the part of the URL after the ?, like /products?sort=asc. It's read-only. To change it, build a new query string and navigate to it.

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

export default function SortButtons() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();

  const sort = searchParams.get('sort') ?? 'asc';

  function changeSort(value) {
    const params = new URLSearchParams(searchParams.toString());
    params.set('sort', value);
    router.push(`${pathname}?${params.toString()}`);
  }

  return (
    <>
      <p>Sorted by: {sort}</p>
      <button onClick={() => changeSort('asc')}>Ascending</button>
      <button onClick={() => changeSort('desc')}>Descending</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wrap the component that uses useSearchParams in a Suspense boundary. On a statically rendered page, the query string isn't known while building, so Next.js needs somewhere to show a fallback:

// app/products/page.js
import { Suspense } from 'react';
import SortButtons from './sort-buttons';

export default function Page() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <SortButtons />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Keeping filters and sorting in the URL is a great habit. Users can refresh the page or share the link and see the same result.

useParams

useParams reads the dynamic parts of the URL. If your file is app/blog/[slug]/page.js and the user visits /blog/react-hooks, then slug is react-hooks.

'use client';

import { useParams } from 'next/navigation';

export default function PostTitle() {
  const { slug } = useParams();
  return <h1>Reading: {slug}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

There is also useSelectedLayoutSegment, which tells a layout which child route is currently active. It's handy for tabs and sidebars.

useLinkStatus

useLinkStatus tells you if a <Link> is still loading its page. It's great for showing a small spinner right inside the link, so the user knows the click worked. You import it from next/link.

It must be used in a component that is rendered inside the <Link>, just like useFormStatus must be inside a <form>.

'use client';

import Link, { useLinkStatus } from 'next/link';

function Spinner() {
  const { pending } = useLinkStatus();
  return pending ? <span> ⏳</span> : null;
}

export default function NavLink({ href, children }) {
  return (
    <Link href={href}>
      {children}
      <Spinner />
    </Link>
  );
}
Enter fullscreen mode Exit fullscreen mode

useServerInsertedHTML

Like useInsertionEffect, this one is for library authors, mainly CSS-in-JS libraries. It lets you insert HTML, like a <style> tag, into the page while it is being rendered on the server. You import it from next/navigation.

'use client';

import { useServerInsertedHTML } from 'next/navigation';

export default function FontStyles() {
  useServerInsertedHTML(() => (
    <style>{`body { font-family: Arial, sans-serif; }`}</style>
  ));

  return null;
}
Enter fullscreen mode Exit fullscreen mode

If you build normal apps, you will almost never write this yourself.

Custom Hooks

A custom Hook is just a function whose name starts with use and that calls other Hooks inside it. That's the whole trick. You take logic that you copied into two components, move it into one function, and use it in both places.

Note: Each component that calls a custom Hook gets its own separate state. Two components using useToggle don't share the same value. They only share the code.

useToggle

The simplest example. It's a true/false switch with a function to flip it.

import { useCallback, useState } from 'react';

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((current) => !current), []);

  return [on, toggle];
}
Enter fullscreen mode Exit fullscreen mode
'use client';

import { useToggle } from './use-toggle';

export default function Menu() {
  const [open, toggleOpen] = useToggle();

  return (
    <>
      <button onClick={toggleOpen}>Menu</button>
      {open && <nav>Home | Blog | Contact</nav>}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

useDebounce

It waits until the user stops typing before it gives you the new value. It's very useful before sending a search request.

import { useEffect, useState } from 'react';

export function useDebounce(value, delay = 400) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timerId = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timerId); // cancel the old timer on every change
  }, [value, delay]);

  return debounced;
}
Enter fullscreen mode Exit fullscreen mode
'use client';

import { useState } from 'react';
import { useDebounce } from './use-debounce';

export default function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  // Use debouncedQuery in your fetch or effect. It changes only after
  // the user stops typing for half a second.
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>Searching for: {debouncedQuery}</p>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

useDebounce waits for a fixed amount of time. useDeferredValue doesn't use a timer. It lets React decide based on how busy the screen is. Use debounce to slow down network requests, and useDeferredValue to keep heavy rendering smooth.

useFetch

Here is a fetch Hook with loading and error states, and a cleanup so an old request can't overwrite a newer one.

import { useEffect, useState } from 'react';

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

  useEffect(() => {
    let ignore = false; // becomes true if the url changes or the component leaves

    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error('Request failed');
        return res.json();
      })
      .then((json) => {
        if (!ignore) setData(json);
      })
      .catch((err) => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

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

  return { data, error, loading };
}
Enter fullscreen mode Exit fullscreen mode
'use client';

import { useFetch } from './use-fetch';

export default function Posts() {
  const { data, error, loading } = useFetch(
    'https://jsonplaceholder.typicode.com/posts?_limit=5'
  );

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Something went wrong.</p>;

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

This is great for learning how Hooks fit together. In a real project, fetch in a Server Component, or use a library like TanStack Query or SWR. They handle caching, retries and much more.

The rules for custom Hooks are the same as for all Hooks:

  • The name must start with use, so React and your linter can recognize it.
  • Call Hooks only at the top level, never inside if, loops or nested functions.
  • Call Hooks only inside components or other custom Hooks.

Common Mistakes to Avoid

1. Calling a Hook after an early return or inside an if

React remembers your Hooks by the order they are called. If the order changes between renders, everything breaks.

// ❌ Wrong
function Profile({ user }) {
  if (!user) return null;
  const [name, setName] = useState(user.name); // Hook after an early return
}

// ✅ Right
function Profile({ user }) {
  const [name, setName] = useState(user?.name ?? '');
  if (!user) return null;
}
Enter fullscreen mode Exit fullscreen mode

The one exception is use, which can be called conditionally.

2. Forgetting 'use client' in Next.js

If you use useState, useEffect or any browser-only Hook in a Server Component, you will see an error saying the Hook only works in a Client Component. Add 'use client' at the top of that file. Keep those files small, and only mark the interactive parts as client code, so the rest of your page stays on the server.

3. Using an effect to calculate something

If you can work it out during rendering, don't use state and an effect for it.

// ❌ Wrong: extra state, extra render
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);

// ✅ Right
const fullName = firstName + ' ' + lastName;
Enter fullscreen mode Exit fullscreen mode

4. Infinite loops in effects

// ❌ Wrong: no dependency array, so this runs after every render
// and setCount triggers another render, forever
useEffect(() => {
  setCount(count + 1);
});
Enter fullscreen mode Exit fullscreen mode

Another sneaky one: an object or array created during render is a new value every time, so an effect that depends on it re-runs on every render. Move it outside the component, or wrap it in useMemo.

5. Wrapping an input's setState in startTransition

Text inputs must update instantly. If you mark that update as low priority, the typing will lag or jump. Keep the input state urgent and use useDeferredValue for the slow part.

6. Calling useFormStatus in the same component as the form

It reads the status of a parent form. Put it in a child component, like the SubmitButton from earlier.

7. Creating a promise while rendering and passing it to use

A new promise is created on every render, so React starts over each time. Create the promise in a Server Component, or use a library that caches it.

8. Using useSearchParams without Suspense

It can cause a build error or turn your whole page into client-rendered content. Wrap the component that uses it in <Suspense>.

Cheatsheet

Keep this open while you code. It has every Hook from both parts.

Hook What it does Basic syntax
useState Remembers a value const [state, setState] = useState(initial)
useEffect Runs side effects useEffect(() => {}, [deps])
useContext Reads shared data const value = useContext(MyContext)
useRef Holds a DOM element or a value that doesn't re-render const ref = useRef(null)
useMemo Remembers a calculated value const value = useMemo(() => compute(), [deps])
useCallback Remembers a function const fn = useCallback(() => {}, [deps])
useReducer Handles complex state logic const [state, dispatch] = useReducer(reducer, initial)
useLayoutEffect Measures the DOM before paint useLayoutEffect(() => {}, [deps])
useImperativeHandle Exposes methods to a parent useImperativeHandle(ref, () => ({ focus() {} }))
useId Creates a stable unique ID const id = useId()
useTransition Marks an update as low priority const [isPending, startTransition] = useTransition()
useDeferredValue Gives a lagging copy of a value const deferred = useDeferredValue(value)
useSyncExternalStore Reads data from outside React useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useInsertionEffect Injects CSS (for library authors) useInsertionEffect(() => {}, [deps])
useDebugValue Adds a label in DevTools useDebugValue(value)
useActionState Tracks the result of a form action const [state, formAction, isPending] = useActionState(action, initial)
useFormStatus Reads the status of the parent form const { pending } = useFormStatus()
useOptimistic Shows a temporary value during an Action const [optimistic, setOptimistic] = useOptimistic(state, reducer)
use Reads a promise or a context const data = use(promise)
useEffectEvent Reads the latest values inside an effect (19.2+) const onEvent = useEffectEvent(() => {})
useRouter Navigates from code (Next.js) const router = useRouter()
usePathname Reads the current path (Next.js) const pathname = usePathname()
useSearchParams Reads the query string (Next.js) const searchParams = useSearchParams()
useParams Reads dynamic route params (Next.js) const { slug } = useParams()
useLinkStatus Knows if a Link is loading (Next.js) const { pending } = useLinkStatus()
useServerInsertedHTML Inserts HTML on the server (Next.js) useServerInsertedHTML(() => html)

The rules in one place

  • Call Hooks at the top level of a component or a custom Hook. Never inside if, loops or nested functions.
  • Custom Hook names start with use.
  • Hooks with state or effects need 'use client' in Next.js.
  • If you can calculate it during render, skip the effect.
  • Always clean up timers and listeners.
  • Measure before you reach for useMemo and useCallback.

Conclusion

That's the full tour of React Hooks. Here is the short version of what to remember:

  • useState, useEffect and useContext cover most of what you will write every day.
  • useTransition and useDeferredValue keep your app smooth when updates get heavy.
  • useActionState, useFormStatus and useOptimistic make forms and instant feedback much simpler in React 19.
  • use and useEffectEvent remove a lot of old workarounds.
  • Next.js Hooks handle routing, and they only work in Client Components.
  • Custom Hooks let you write logic once and use it everywhere.

You don't need to memorize all of them. Learn the core ones well, and come back to the rest when you need them. That's what the cheatsheet is for.

If this guide helped you, leave a ❤️ or a 🦄, and share it with someone who is learning React. Questions, or a Hook I should explain better? Drop a comment below. Thanks for reading! 🙌

Top comments (0)