Home / Articles / A Reusable Custom Hooks Toolkit for Every New React Project

This article is published in English.

A Reusable Custom Hooks Toolkit for Every New React Project

Explore a curated set of custom React hooks—covering storage, debouncing, clicks, and fetching—that eliminate repetitive boilerplate in new projects.

3840 words

React hooks have become the default way to manage state, side effects, and reusable logic in modern applications, but knowing the built-in hooks is only half the story. Just as valuable is having a personal toolkit of small, well-tested custom hooks that you can drop into any new project to avoid rebuilding the same utilities from scratch. This article first walks through a set of hooks worth keeping in your starter kit, then goes deeper into how the underlying hook APIs actually work so you understand not just what to copy, but why it behaves the way it does.

When starting a new React project, certain hooks tend to reappear almost every time. Some address performance concerns, others smooth out the user experience, and several simply prevent you from rewriting the same boilerplate logic in every codebase. Over repeated projects, these utilities turn into a standard starter set: rather than solving the same problem from zero, you bring in the hook, tweak it if the project needs something slightly different, and get back to building actual features. Below are twelve hooks that fit that description.

The first is useLocalStorage, useful because nearly every application needs to persist something across reloads, whether that's a theme preference, form input, or other user settings.

import { useState } from "react";
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) : initialValue;
  });  const updateValue = (newValue) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };  return [value, updateValue];
}

This pattern is commonly reached for when storing things like dark mode preference, an auth token, or any setting you want remembered between visits.

Next is useToggle, for the common case where a piece of state just flips between true and false.

import { useState } from "react";
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);  const toggle = () => setValue(v => !v);  return [value, toggle];
}

It's a natural fit for modals, dropdown menus, sidebars, and similar toggled UI elements.

useDebounce comes up constantly around search inputs, where you don't want to fire a request on every keystroke.

import { useState, useEffect } from "react";
function useDebounce(value, delay = 500) {
  const [debounced, setDebounced] = useState(value);  useEffect(() => {
    const timer = setTimeout(() => {
      setDebounced(value);
    }, delay);    return () => clearTimeout(timer);
  }, [value, delay]);  return debounced;
}

By delaying the update until typing pauses, it cuts down on wasted API calls.

useWindowSize gives responsive layouts access to the current viewport dimensions.

import { useState, useEffect } from "react";
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });  useEffect(() => {
    const resize = () =>
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });    window.addEventListener("resize", resize);    return () => window.removeEventListener("resize", resize);
  }, []);  return size;
}

usePrevious is handy whenever you need to compare a value against what it was on the last render.

import { useEffect, useRef } from "react";
function usePrevious(value) {
  const ref = useRef();  useEffect(() => {
    ref.current = value;
  }, [value]);  return ref.current;
}

This is particularly useful for animations or for detecting when something has actually changed.

useClickOutside closes UI elements like modals when the user clicks anywhere outside them, which matches how users intuitively expect these components to behave.

import { useEffect } from "react";
function useClickOutside(ref, callback) {
  useEffect(() => {
    function handleClick(e) {
      if (ref.current && !ref.current.contains(e.target)) {
        callback();
      }
    }    document.addEventListener("mousedown", handleClick);    return () =>
      document.removeEventListener("mousedown", handleClick);
  }, [ref, callback]);
}

It works well for dropdowns, popovers, and mobile navigation menus.

useDocumentTitle keeps the browser tab title in sync with the current view, which helps with navigation and orientation.

import { useEffect } from "react";
function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

Rather than duplicating the same effect across many components, you call this one hook wherever a page needs a custom title.

useFetch wraps basic data fetching in a reusable hook.

import { useState, useEffect } from "react";
function useFetch(url) {
  const [data, setData] = useState(null);  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData);
  }, [url]);  return data;
}

For anything beyond small projects, tools like React Query or SWR are usually a better fit, but this lightweight version is enough to get a small app off the ground.

useCopyToClipboard addresses the now-ubiquitous copy button.

function useCopyToClipboard() {
  const copy = (text) => {
    navigator.clipboard.writeText(text);
  };
  return copy;
}

It's a good match for sharing invite links, coupon codes, or API keys with a single click.

useOnlineStatus lets your app react when the network connection drops or comes back.

import { useState, useEffect } from "react";
function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);  useEffect(() => {
    window.addEventListener("online", () => setOnline(true));
    window.addEventListener("offline", () => setOnline(false));
  }, []);  return online;
}

It's a small addition, but it noticeably improves how an app feels when connectivity is unreliable.

useDarkMode handles the dark mode toggle that most users expect from modern apps today.

import { useEffect } from "react";
function useDarkMode(enabled) {
  useEffect(() => {
    document.body.classList.toggle("dark", enabled);
  }, [enabled]);
}

It's commonly paired with useLocalStorage so the chosen mode persists across sessions.

Finally, useTimeout makes working with delays much tidier than scattering raw setTimeout calls through your components.

import { useEffect } from "react";
function useTimeout(callback, delay) {
  useEffect(() => {
    const timer = setTimeout(callback, delay);    return () => clearTimeout(timer);
  }, [callback, delay]);
}

It suits notifications, splash screens, and any action that needs to fire after a delay.

Across many React projects, one lesson holds up consistently: you don't need to rebuild everything from scratch each time. Keeping a small library of reusable hooks speeds up development, keeps components easier to read, and eliminates repetitive code. None of these twelve hooks are especially complex, but together they save a substantial amount of time over the course of a project. As your own projects grow, you'll likely accumulate a similar personal collection — and what matters isn't the total number of hooks you have, but whether they solve the problems you actually run into again and again. A small utility you write today for one project often turns into something you reach for in every project afterward.

Understanding a personal library of hooks helps day to day, but it's just as valuable to understand the mechanics underneath them — what each core hook solves, when to use it, and how it behaves across renders. This deeper foundation is where hook usage stops being copy-paste and starts being genuine understanding.

Before Hooks, function components couldn't hold state or run side effects, so that logic lived in class components with a state object and lifecycle methods:

class Counter extends React.Component {
  state = {
    count: 0
  };

  increment = () => {
    this.setState({
      count: this.state.count + 1
    });
  };

  render() {
    return (
      <button onClick={this.increment}>
        {this.state.count}
      </button>
    );
  }
}

With Hooks, the same component becomes a plain function calling useState directly:

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

  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  );
}

The functional version is usually easier to read and reuse.

Two rules govern Hooks. First, always call them at the top level — never inside conditionals, loops, or nested functions. Avoid this:

if (isLoggedIn) {
  useEffect(() => {
    // ❌
  }, []);
}


for (...) {
  useState(0); // ❌
}

Instead, keep the Hook call unconditional and put conditional logic afterward:

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

  if (count > 10) {
    // Normal conditional logic is fine
  }

  return ...;
}

This rule exists because React tracks state by call order, not by name. Given two useState calls:

function Component() {
  const [count] = useState(0);
  const [name] = useState("Salim");

  return ...;
}

React lines them up positionally:

Hook #1 → count
Hook #2 → name

If the first call becomes conditional:

if (condition) {
  useState(0);
}

useState("Salim");

the order can shift between renders:

Render 1:
Hook #1 → count
Hook #2 → name

Render 2:
Hook #1 → name

Once that happens, React can no longer match stored state to the right call, so order must stay fixed every render.

useState lets a component remember a value across renders:

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

It returns a pair:

count     → current state
setCount  → state update function

A basic counter shows the pattern:

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

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Clicking the button triggers this sequence:

Click
 ↓
setCount()
 ↓
React schedules update
 ↓
Component renders again
 ↓
New state is calculated
 ↓
DOM is updated if necessary

State isn't an ordinary variable like let count = 0, since that wouldn't survive the function re-running. React stores it outside the component and supplies the right value each render:

Render 1
count = 0

Render 2
count = 1

Render 3
count = 2

The component re-executes, but React preserves the state between executions.

This matters when updating state multiple times in one handler:

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

If the render started with count at 0, all three lines use that same snapshot, giving:

setCount(1)
setCount(1)
setCount(1)

instead of three real increments. The fix is a functional update:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

Now React applies each update in sequence, since every updater receives the latest value — useful whenever new state depends on old state.

useEffect synchronizes a component with something outside React:

useEffect(() => {
  // synchronization logic
}, [dependencies]);

Typical cases include API calls, timers, event listeners, WebSockets, other browser APIs, subscriptions, and third-party libraries. Example:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  return <h1>{user?.name}</h1>;
}

This effect is synchronized with userId; when it changes, the effect must re-run.

That behavior comes from the dependency array. Given:

useEffect(() => {
  console.log(count);
}, [count]);

React compares dependencies conceptually:

Previous dependencies
        ↓
New dependencies
        ↓
Compare
        ↓
Changed?

If they differ, the effect runs again:

Previous: [1]
New:      [2]

→ Effect needs to run

If they match, it's skipped:

Previous: [2]
New:      [2]

→ Effect can be skipped

Comparison follows Object.is, not deep equality.

Effects can return a cleanup function, run before the next effect and on unmount:

useEffect(() => {
  const timer = setInterval(() => {
    console.log("tick");
  }, 1000);

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

Cleanup matters for things like:

Timers
Event listeners
Subscriptions
WebSocket connections
Abortable requests

When dependencies change, React cleans up the old effect before running the new one; it also cleans up when the component unmounts.

A search input shows this in practice — each keystroke can fire a request:

react
react hooks
react performance

Since an outdated response could overwrite a newer one, cancel the previous request:

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

  fetch(`/api/search?q=${query}`, {
    signal: controller.signal
  })
    .then(res => res.json())
    .then(setResults)
    .catch(error => {
      if (error.name !== "AbortError") {
        console.error(error);
      }
    });

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

The lifecycle looks like this:

query changes
     ↓
cleanup previous effect
     ↓
abort previous request
     ↓
start new request

Real search inputs typically pair this with debouncing.

A common mistake is using useEffect for values that can be derived directly:

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

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

Since fullName comes straight from existing values, skip the effect and state entirely:

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

The effect version adds unneeded synchronization and an extra render. Rule of thumb: if you can compute it during render, you don't need an effect for it.

useRef gives a stable object whose .current persists across renders without triggering re-renders when it changes:

const ref = useRef(initialValue);

A common use is referencing a DOM node, such as focusing an input:

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

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

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>
        Focus
      </button>
    </>
  );
}

useRef is also handy for storing mutable values that don't belong in the rendered output at all, such as a timer identifier:

const timerRef = useRef(null);

You then assign to it directly, the same way you'd assign to any object property:

timerRef.current = setInterval(...);

Mutating

ref.current

does not by itself trigger a re-render. Contrast the two mental models:

useState
→ update → render

useRef
→ mutate .current → no render

This makes refs a good fit for values that need to persist between renders but shouldn't influence what gets drawn on screen.

useMemo takes a different approach: it caches the result of a calculation rather than a DOM reference.

const result = useMemo(() => {
  return expensiveCalculation(data);
}, [data]);

Think of it as: useMemo = cache a calculation. A typical use case filters a list only when the underlying data actually changes:

function ProductList({ products, search }) {
  const filteredProducts = useMemo(() => {
    return products.filter(product =>
      product.name
        .toLowerCase()
        .includes(search.toLowerCase())
    );
  }, [products, search]);

  return <ProductGrid products={filteredProducts} />;
}

If some unrelated piece of state updates, React can just hand back the previously computed value as long as the dependencies are the same.

Conceptually, React keeps a small record attached to the hook call:

Hook
 ├── memoized value
 └── dependencies

On the first render:

data = A

calculate(A)
 ↓
result = X

On a later render, if data is still A, the dependency comparison passes and React reuses X without recalculating. Only when data becomes something like B does React redo the computation.

That said, useMemo is easy to overuse. Wrapping something like this:

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

is rarely worth it — the computation is trivial, and memoization itself isn't free; it adds overhead and extra code to reason about. Reach for useMemo when the calculation is genuinely expensive, when you need a stable reference to an object or array, or when profiling (or clear reasoning) shows it actually helps.

useCallback is the equivalent tool for function references instead of values.

const handleClick = useCallback(() => {
  doSomething();
}, []);

This matters because ordinary functions get recreated on every render:

function Parent() {
  const handleClick = () => {
    console.log("clicked");
  };

  return <Child onClick={handleClick} />;
}

On the first render handleClick points to one function instance; on the second render it points to a different one, so the two are not equal even though they do the same thing.

That inequality becomes a problem when a child component is wrapped in React.memo:

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

  return (
    <button onClick={onClick}>
      Click
    </button>
  );
});

and the parent looks like this:

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

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

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

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

When count changes:

Parent renders
      ↓
handleClick reference remains stable
      ↓
React.memo checks Child props
      ↓
onClick unchanged
      ↓
Child can skip rendering

Without useCallback, the freshly created function reference would be seen as "new" by the memoized child, causing it to re-render even though nothing meaningful changed for it.

Still, useCallback isn't something to sprinkle over every function by default. Before wrapping a handler, ask whether anything actually benefits from a stable reference — for example:

React.memo child
Dependency array
Expensive downstream computation

If none of those apply, letting the function be recreated normally is usually fine.

The distinction between the two hooks comes down to what they memoize:

useMemo
→ memoizes a VALUE

useCallback
→ memoizes a FUNCTION

Conceptually:

useMemo(() => calculateValue(), deps);

useCallback(() => doSomething(), deps);

Moving from performance-oriented hooks to a structural one, the Context API tackles a different problem: passing data through many layers of nested components. Without it, a value like the current user might need to travel through every level in between:

App
 ↓
Navbar
 ↓
UserMenu
 ↓
Profile
 ↓
Avatar

resulting in something like:

<App user={user} />
<Navbar user={user} />
<UserMenu user={user} />
<Profile user={user} />
<Avatar user={user} />

This pattern of forwarding props through components that don't otherwise need them is known as prop drilling.

Setting up context starts with a creation call:

const UserContext = createContext(null);

Then you supply a value with a provider:

function App() {
  const user = {
    name: "Salim",
    role: "Developer"
  };

  return (
    <UserContext.Provider value={user}>
      <Dashboard />
    </UserContext.Provider>
  );
}

and read it wherever it's needed:

function Profile() {
  const user = useContext(UserContext);

  return <h1>Hello {user.name}</h1>;
}

Profile no longer needs user handed down through every intermediate component.

A common real-world case is theming:

const ThemeContext = createContext(null);

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

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

Any component can then read the current theme directly:

function Button() {
  const { theme } = useContext(ThemeContext);

  return (
    <button className={theme}>
      Submit
    </button>
  );
}

producing a tree shaped roughly like:

App
 │
 └── ThemeProvider
       │
       └── Dashboard
             │
             └── Button

Button picks up the theme value without any prop drilling.

It's worth being clear that Context is not automatically a full state-management solution. It answers one specific question: how do you make a value reachable by components deep in the tree? It doesn't provide the extra machinery — selectors, middleware, structured updates — that a dedicated library offers. For more elaborate state needs, you might reach for:

Redux
Zustand
Jotai
Reducer + Context

Context is best thought of as a value-distribution mechanism, not a state manager.

It also has implications for rendering. Given:

<ThemeContext.Provider value={theme}>

whenever that context's value changes, the components consuming it can re-render. Context doesn't magically prevent re-renders — putting a large, frequently changing object into a broadly consumed context can actually cause avoidable work. Better candidates for context are values that change infrequently, such as:

Theme
Locale
Authentication information
Feature flags
Application configuration

Custom Hooks let you package up reusable logic built from the built-in hooks. A minimal example:

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

  function increment() {
    setCount(c => c + 1);
  }

  return {
    count,
    increment
  };
}

used like this:

function Counter() {
  const { count, increment } = useCounter();

  return (
    <button onClick={increment}>
      Count: {count}
    </button>
  );
}

The real value here is reusing logic, not reusing markup.

This is worth stressing: custom Hooks share logic, not state. If two separate components each call the same custom Hook:

const counterA = useCounter();
const counterB = useCounter();

they do not end up sharing a single count. Each call gets its own independent state. Conceptually:

Component A
 └── useCounter
      └── useState → State A

Component B
 └── useCounter
      └── useState → State B

A custom Hook packages up behavior, not a shared data store.

As a concrete example, imagine an app needs to display whether the user currently has a network connection. Rather than duplicating the browser event-listener logic in every component that needs it, you can wrap it in a custom Hook:

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine
  );

  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }

    function handleOffline() {
      setIsOnline(false);
    }

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return isOnline;
}

Any component that needs connectivity status can now consume it directly:

function Navbar() {
  const isOnline = useOnlineStatus();

  return (
    <span>
      {isOnline ? "🟢 Online" : "🔴 Offline"}
    </span>
  );
}

Another component might use the same Hook to change its own rendering entirely:

function Checkout() {
  const isOnline = useOnlineStatus();

  if (!isOnline) {
    return <p>You are offline.</p>;
  }

  return <PaymentForm />;
}

A similar pattern applies to debouncing rapidly changing input:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

used inside a search feature like this:

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

  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (!debouncedQuery) return;

    // Search API
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

which produces this sequence of events:

User types
    ↓
query changes
    ↓
500ms wait
    ↓
debouncedQuery changes
    ↓
API request

These building blocks are meant to be combined, not used in isolation:

                 React Component
                       │
       ┌───────────────┼────────────────┐
       │               │                │
   useState        useEffect        useContext
       │               │                │
    UI state       External systems   Shared data
       │
       └───────────────┐
                       │
                 Custom Hook
                       │
             ┌─────────┼─────────┐
             ▼         ▼         ▼
         useState   useEffect   useMemo

A custom useProducts() Hook, for instance, might internally rely on:

useState
+
useEffect
+
useMemo

while pulling authentication state from useContext. Re-renders follow a predictable path:

State / Props / Context change
          ↓
      React update
          ↓
       Render
          ↓
   Reconciliation
          ↓
       Commit
          ↓
      DOM update
          ↓
      Browser paint

with each hook playing a distinct role, summarized here:

useState
→ provides state + schedules updates

useMemo
→ calculates/reuses values during render

useCallback
→ calculates/reuses function references during render

useContext
→ reads context during render

useEffect
→ synchronizes with external systems after commit