DEV Community

Cover image for 5 Places Custom React Hooks Make Your Code Better
Janarthanan Soundararajan (Jana)
Janarthanan Soundararajan (Jana)

Posted on AI-assisted

5 Places Custom React Hooks Make Your Code Better

You add a search box to a page. A timer delays the search until the user stops typing.

Then another page needs the same behavior. You copy the timer, the effect, and the cleanup.

A few weeks later, someone changes the delay in one place. Another search box behaves differently. A third forgets to clear its timer.

The problem isn't that the code is long. It's that the same behavior now has several owners.

That's where a custom hook helps.

In this article, we'll build five hooks for situations that show up in real applications:

Hook Coordinates Primary Real-World Use Case
⏱️ useDebouncedValue Input value & timer Delaying network searches & heavy validation
🌐 useOnlineStatus Browser connectivity store Showing "Offline" alerts & pausing remote sync
📱 useMediaQuery Media query matches Adapting layout logic & honoring prefers-reduced-motion
💾 useLocalStorageState React state & storage Persisting drafts, filters, and UI preferences safely
👁️ useInView DOM element & IntersectionObserver Scroll-triggered animations, lazy widgets, & impression tracking

We'll also cover when a hook is the wrong abstraction.

🧩 First: what belongs in a custom hook?

A custom hook is a function that uses React hooks to package reusable behavior. Its name starts with use:

function useSomething() {
  // React hooks can be used here.
}
Enter fullscreen mode Exit fullscreen mode

But adding use to a function name doesn't make it a useful hook.

If you're calculating a total, use a regular function:

function calculateTotal(items) {
  return items.reduce(
    (total, item) => total + item.price,
    0
  );
}
Enter fullscreen mode Exit fullscreen mode

If you're managing a timer, subscribing to a browser event, or coordinating state and cleanup, a hook may be appropriate.

A useful hook usually has a clear contract:

Given these inputs, expose this state or behavior, and manage its lifecycle.

For example:

const delayedSearch = useDebouncedValue(search, 350);
Enter fullscreen mode Exit fullscreen mode

The component shouldn't need to know how the timer is created or cleaned up.

One important distinction: custom hooks share logic, not automatically state. Two calls to a hook containing useState normally have independent state. Hooks subscribing to the same external source can observe the same underlying data. React's custom hook guide

Call hooks at the top level of a component or another hook, not inside conditions, loops, or event handlers.

The examples below use JavaScript. They are deliberately small, with their limitations explained rather than hidden behind a "production-ready" label.

⏱️ 1. useDebouncedValue: wait until input settles

The problem

A user types:

r → re → rea → reac → react
Enter fullscreen mode Exit fullscreen mode

If every change triggers expensive work, you may perform five operations when the user only wanted the final result. Debouncing waits for a quiet period.

Debounce Timing Diagram

Implementation

import { useEffect, useState } from "react";

export function useDebouncedValue(value, delay = 350) {
  const [debouncedValue, setDebouncedValue] =
    useState(value);

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

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

  return debouncedValue;
}
Enter fullscreen mode Exit fullscreen mode

How it works

The hook keeps two values:

  • value: the latest input.
  • debouncedValue: the last input that stayed unchanged long enough.

When the input changes, the effect starts a timer.

If the input changes again before the delay ends, cleanup cancels the previous timer. A new timer starts for the new value.

The same cleanup also cancels a pending timer when the component unmounts. Both value and delay are dependencies because changing either should restart the waiting period.

Usage

Keep the input responsive. Debounce the work that follows it.

import { useMemo, useState } from "react";
import { useDebouncedValue } from "./useDebouncedValue";

export function ProductSearch({ products }) {
  const [search, setSearch] = useState("");
  const debouncedSearch =
    useDebouncedValue(search, 350);

  const results = useMemo(() => {
    const query =
      debouncedSearch.trim().toLowerCase();

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

  return (
    <section>
      <label>
        Search products
        <input
          value={search}
          onChange={(event) =>
            setSearch(event.target.value)
          }
        />
      </label>

      <p>{results.length} products found</p>

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

For a small local list, immediate filtering may feel better. Debouncing becomes useful when the work is expensive or involves a request.

What this hook does not solve

Debouncing doesn't cancel a request that has already started.

If a request for "rea" is still running when a request for "react" begins, the older response could arrive last. For network searches, also handle cancellation or stale responses—often with an AbortController or a data-fetching library.

[!NOTE]
useDebouncedValue vs. React's useDeferredValue

  • useDeferredValue (built into React 18+) addresses CPU-bound rendering responsiveness. It tells React to keep the input responsive while deferring an expensive list re-render in the background without any fixed delay.
  • useDebouncedValue addresses I/O, network requests, and timers. It enforces a fixed quiet period (e.g. 350ms) before triggering remote API requests, database queries, or server-side validations.

Good uses: search requests, delayed previews, validation after typing pauses.

Avoid: debouncing the input's own displayed value. That makes typing feel broken.

🌐 2. useOnlineStatus: subscribe to connection changes

The problem

Several components may need to show a connection warning. You could add online and offline listeners inside each component. A hook gives those components one consistent interface.

Here, the browser is the source of truth. useSyncExternalStore is designed for subscribing to state outside React. React's external-store reference

Implementation

import { useSyncExternalStore } from "react";

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

  return () => {
    window.removeEventListener(
      "online",
      onStoreChange
    );

    window.removeEventListener(
      "offline",
      onStoreChange
    );
  };
}

function getSnapshot() {
  return typeof navigator !== "undefined" ? navigator.onLine : true;
}

function getServerSnapshot() {
  return true;
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works

There are three pieces:

  • subscribe tells React when the browser's status changes.
  • getSnapshot reads the current value.
  • getServerSnapshot supplies a value during server rendering and initial hydration.

The server cannot inspect the user's browser connection. Returning true is our chosen initial assumption, not a measurement.

The subscription functions live outside the hook because they don't depend on component props. Their identities remain stable between renders.

Usage

import { useOnlineStatus } from "./useOnlineStatus";

export function ConnectionNotice() {
  const isOnline = useOnlineStatus();

  if (isOnline) {
    return null;
  }

  return (
    <p role="status">
      You appear to be offline. Recent changes may
      not have synced yet.
    </p>
  );
}
Enter fullscreen mode Exit fullscreen mode

The important limitation

navigator.onLine is a hint, not proof that your API is reachable. A device may have a network connection while your server is unavailable. VPNs and other network conditions can also affect the result. MDN's explanation of navigator.onLine

Use this hook for helpful UI messaging. Don't use it as the only reason to disable a submit button. Try the operation and handle its actual result.

Good uses: connection banners, offline indicators, explaining delayed synchronization.

Avoid: treating it as a backend health check.

📱 3. useMediaQuery: use browser preferences in component behavior

The problem

CSS handles responsive presentation well:

.cards {
  display: grid;
  grid-template-columns: 1fr;
}

@media (min-width: 900px) {
  .cards {
    grid-template-columns: repeat(3, 1fr);
  }
}
Enter fullscreen mode Exit fullscreen mode

You don't need a hook for that.

But sometimes a browser preference changes JavaScript behavior. For example, you may want to stop a canvas animation when the user prefers reduced motion.

matchMedia exposes a media query's current result and change events. MDN's matchMedia reference

Implementation

import {
  useCallback,
  useMemo,
  useSyncExternalStore,
} from "react";

export function useMediaQuery(
  query,
  serverFallback = false
) {
  const mediaQuery = useMemo(() => {
    if (typeof window === "undefined") {
      return null;
    }

    return window.matchMedia(query);
  }, [query]);

  const subscribe = useCallback(
    (onStoreChange) => {
      if (!mediaQuery) {
        return () => {};
      }

      mediaQuery.addEventListener(
        "change",
        onStoreChange
      );

      return () => {
        mediaQuery.removeEventListener(
          "change",
          onStoreChange
        );
      };
    },
    [mediaQuery]
  );

  const getSnapshot = useCallback(
    () => mediaQuery?.matches ?? serverFallback,
    [mediaQuery, serverFallback]
  );

  const getServerSnapshot = useCallback(
    () => serverFallback,
    [serverFallback]
  );

  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works

The query creates a MediaQueryList. The hook subscribes to its change event and exposes its boolean matches value.

If the query changes, the hook creates a new subscription and removes the old one.

The server fallback matters because a server doesn't know the browser's viewport or preferences. Use the same fallback on the server and client for initial hydration.

Usage

import { useMediaQuery } from "./useMediaQuery";

export function Hero() {
  const prefersReducedMotion = useMediaQuery(
    "(prefers-reduced-motion: reduce)",
    true
  );

  return (
    <section>
      <h1>Build something worth showing.</h1>

      {prefersReducedMotion ? (
        <StaticIllustration />
      ) : (
        <AnimatedIllustration />
      )}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

StaticIllustration and AnimatedIllustration represent your application's illustration components.

Here, true is a conservative initial fallback: start with the static illustration until the browser's preference is available.

What to avoid

Don't replace every CSS media query with React state. Use CSS for spacing, columns, font sizes, and element positioning.

Use this hook when JavaScript behavior genuinely depends on the query.

Also remember that conditionally replacing components can discard their local state. Switching between a mobile and desktop form should not accidentally erase the user's input.

Good uses: reduced-motion behavior, enabling an interactive visualization, adapting controls.

Avoid: measuring viewport width in React just to change a margin.

💾 4. useLocalStorageState: remember a preference or draft

The problem

A user selects a preference or writes a draft. Refreshing the page loses it. A storage hook can coordinate React state with browser persistence.

But this hook needs more care than:

localStorage.setItem(key, JSON.stringify(value));
Enter fullscreen mode Exit fullscreen mode

Reading can fail. Writing can fail. Stored JSON can be invalid. And an initial render must not overwrite an existing saved value before it has been read.

Implementation

This version keeps component-local state and persists changes after loading.

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

export function useLocalStorageState(
  key,
  initialValue
) {
  const [fallback] = useState(() => initialValue);

  const [record, setRecord] = useState(() => ({
    key,
    value: fallback,
    ready: false,
    dirty: false,
  }));

  const [error, setError] = useState(null);

  useEffect(() => {
    let value = fallback;
    let readError = null;

    try {
      const stored = window.localStorage.getItem(key);

      if (stored !== null) {
        value = JSON.parse(stored);
      }
    } catch (caught) {
      readError = caught;
    }

    setError(readError);

    setRecord({
      key,
      value,
      ready: true,
      dirty: false,
    });
  }, [key, fallback]);

  useEffect(() => {
    if (
      record.key !== key ||
      !record.ready ||
      !record.dirty
    ) {
      return;
    }

    try {
      if (record.value === undefined) {
        window.localStorage.removeItem(key);
      } else {
        const serialized = JSON.stringify(record.value);

        if (serialized === undefined) {
          throw new Error(
            "The value cannot be stored as JSON."
          );
        }

        window.localStorage.setItem(
          key,
          serialized
        );
      }

      setError(null);
    } catch (caught) {
      setError(caught);
    }
  }, [key, record]);

  const setValue = useCallback(
    (nextValue) => {
      setRecord((previous) => {
        if (
          previous.key !== key ||
          !previous.ready
        ) {
          return previous;
        }

        const value =
          typeof nextValue === "function"
            ? nextValue(previous.value)
            : nextValue;

        return {
          ...previous,
          value,
          dirty: true,
        };
      });
    },
    [key]
  );

  const isReady =
    record.key === key && record.ready;

  return {
    value:
      record.key === key
        ? record.value
        : fallback,
    setValue,
    isReady,
    error,
  };
}
Enter fullscreen mode Exit fullscreen mode

Why the extra state?

ready means the initial read has finished. dirty means the user has changed the value.

Keeping those separate prevents the default value from immediately replacing a saved value.

It also means that invalid stored JSON is not silently overwritten during loading. The fallback is shown, an error is exposed, and a subsequent user edit can replace the invalid value.

The record includes its storage key so a key change cannot accidentally write the previous key's value into the new key.

Like useState, this hook treats initialValue as an initial fallback—not a prop that continuously resets the value.

useLocalStorageState Lifecycle Flow

Usage

import {
  useLocalStorageState,
} from "./useLocalStorageState";

export function ArticleDraft() {
  const {
    value: draft,
    setValue: setDraft,
    isReady,
    error,
  } = useLocalStorageState(
    "article-draft:v1",
    ""
  );

  return (
    <section>
      <label>
        Your draft
        <textarea
          value={draft}
          disabled={!isReady}
          onChange={(event) =>
            setDraft(event.target.value)
          }
        />
      </label>

      {!isReady && <p>Loading saved draft…</p>}

      {error && (
        <p role="status">
          Browser storage is unavailable or the
          saved draft could not be read. You can
          keep editing, but changes may not persist.
        </p>
      )}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

If a write fails, React state still updates. The user can keep typing, but the interface warns that persistence may have failed.

Know the boundaries

localStorage stores strings, is synchronous, and may be unavailable because of browser policies. It is not a database or a guaranteed backup. MDN's localStorage reference

This implementation also:

  • Accepts JSON-compatible values, not arbitrary JavaScript objects.
  • Does not validate the shape of parsed data.
  • Does not synchronize separate hook instances or browser tabs.
  • Does not guarantee the latest edit survives an immediate unmount.
  • Does not make sensitive data safe to store.

For object data, validate the parsed structure and plan migrations. A versioned key such as "preferences:v2" can help, but changing a key is not itself a migration strategy.

If you need cross-tab updates, subscribe to storage changes. Remember that the browser's storage event fires in other documents—not the window that performed the write. Same-page synchronization needs an additional mechanism. MDN's storage event reference

[!TIP]
Cross-Tab Synchronization Tip:
To sync state between open browser tabs in real-time, add an effect that listens to window.addEventListener("storage", (e) => { if (e.key === key) { ... } }). When a user toggles dark mode or updates a preference in Tab A, Tab B updates automatically.

Good uses: lightweight preferences, dismissed notices, non-sensitive drafts.

Avoid: passwords, tokens, large frequently updated datasets, or business-critical persistence.

👁️ 5. useInView: observe visibility without scroll calculations

The problem

You want to start an animation or mount an expensive preview when a section becomes visible.

One approach is to listen to every scroll event and calculate element positions. Another is to let the browser observe the element.

IntersectionObserver reports changes in an element's intersection with a viewport or specified root. MDN's Intersection Observer guide

Implementation

This hook accepts configuration options including an optional triggerOnce flag:

import { useEffect, useState } from "react";

export function useInView({
  root = null,
  rootMargin = "0px",
  threshold = 0.25,
  triggerOnce = false,
} = {}) {
  const [element, setElement] = useState(null);
  const [isInView, setIsInView] = useState(false);

  useEffect(() => {
    setIsInView(false);

    if (!element) {
      return;
    }

    if (
      typeof IntersectionObserver === "undefined"
    ) {
      setIsInView(true);
      return;
    }

    let active = true;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!active) {
          return;
        }

        const inView =
          entry.isIntersecting &&
          entry.intersectionRatio >= threshold;

        setIsInView(inView);

        if (inView && triggerOnce) {
          observer.disconnect();
        }
      },
      {
        root,
        rootMargin,
        threshold,
      }
    );

    observer.observe(element);

    return () => {
      active = false;
      observer.disconnect();
    };
  }, [element, root, rootMargin, threshold, triggerOnce]);

  return {
    ref: setElement,
    isInView,
  };
}
Enter fullscreen mode Exit fullscreen mode

How it works

The returned callback ref (ref: setElement) tells the hook which element to observe. Using a callback ref is important: unlike a standard useRef(), whose .current assignment does not notify React or trigger effects, a callback ref guarantees that React runs your setup effect as soon as the DOM node attaches or detaches.

When the target element changes, the old observer disconnects and a new one starts.

The active flag prevents a late callback from an obsolete observer from updating the current state. If triggerOnce is set to true, the observer disconnects after its first intersection, keeping the element active (ideal for one-time enter animations).

For this hook, "in view" means:

The element is intersecting, and its visible ratio meets the configured threshold.

That explicit ratio check matters. isIntersecting alone does not mean the element has reached a threshold such as 25%.

Usage

import { useInView } from "./useInView";

export function DemoSection() {
  const { ref, isInView } = useInView({
    threshold: 0.25,
  });

  return (
    <section
      ref={ref}
      style={{ minHeight: 360 }}
    >
      <h2>Try the interactive demo</h2>

      {isInView ? (
        <ExpensivePreview />
      ) : (
        <p>The preview loads as you scroll here.</p>
      )}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

ExpensivePreview represents your application's interactive preview component.

The stable wrapper height matters. If mounting and unmounting the preview changes the section's geometry dramatically, visibility can oscillate.

This example unmounts the preview when it leaves view. If the preview has state that should survive, keep it mounted after the first visit or pause its work instead.

What to avoid

Don't hide essential content permanently if observation is unavailable. This implementation falls back to true.

For infinite scrolling, visibility is only one signal. You also need guards such as:

if (isInView && hasNextPage && !isFetching) {
  // Request the next page.
}
Enter fullscreen mode Exit fullscreen mode

Otherwise, repeated visibility updates can trigger duplicate requests.

For ordinary images, native lazy loading may be enough:

<img
  src="/preview.webp"
  alt="Application preview"
  loading="lazy"
/>
Enter fullscreen mode Exit fullscreen mode

Good uses: optional previews, scroll-triggered animation, pausing offscreen work.

Avoid: replacing simpler browser features without a reason.

Writing a useful hook is about its contract

These five hooks wrap different things:

Hook What it coordinates
useDebouncedValue A value and a timer
useOnlineStatus Browser connection events
useMediaQuery A browser query subscription
useLocalStorageState Local state and persistence
useInView A DOM element and an observer

Before extracting a hook, answer these questions:

  • What does it own?
  • What inputs can change?
  • What does it return?
  • What must be cleaned up?
  • What happens if the browser feature fails?
  • Are separate callers independent or synchronized?

Naming helps reveal the contract. useInView tells the caller what it provides. useMountEffect mostly tells the caller when something runs, while hiding what it does.

Prefer hooks organized around a behavior rather than wrappers that obscure effect dependencies.

A hook is not an excuse to add another effect

Some values should simply be calculated during rendering:

const fullName = `${firstName} ${lastName}`;
Enter fullscreen mode Exit fullscreen mode

They don't need another state variable, effect, or custom hook.

Likewise, behavior caused directly by a user action often belongs in the event handler.

Effects are most useful when coordinating React with something outside it: a timer, event subscription, observer, or browser storage. React's guide to avoiding unnecessary effects

In frameworks with React Server Components, use these hooks within the appropriate client-component boundary. Browser APIs should not be assumed to exist on the server.

TypeScript definitions

For TypeScript projects, here are the ready-to-use type signatures for all five hooks:

View TypeScript signatures

// 1. useDebouncedValue
export function useDebouncedValue(value: T, delay?: number): T;

// 2. useOnlineStatus
export function useOnlineStatus(): boolean;

// 3. useMediaQuery
export function useMediaQuery(
  query: string,
  serverFallback?: boolean
): boolean;

// 4. useLocalStorageState
export interface LocalStorageStateReturn {
  value: T;
  setValue: (value: T | ((prev: T) =&gt; T)) =&gt; void;
  isReady: boolean;
  error: Error | null;
}

export function useLocalStorageState(
  key: string,
  initialValue: T
): LocalStorageStateReturn;

// 5. useInView
export interface UseInViewOptions {
  root?: Element | Document | null;
  rootMargin?: string;
  threshold?: number;
  triggerOnce?: boolean;
}

export interface UseInViewReturn {
  ref: (node: Element | null) =&gt; void;
  isInView: boolean;
}

export function useInView(
  options?: UseInViewOptions
): UseInViewReturn;
Enter fullscreen mode Exit fullscreen mode

Before you reuse a hook, test its awkward cases

The happy path is usually easy. Cleanup and failure cases deserve attention.

For these examples, check:

  • Debounce: rapid changes cancel older timers; unmounting cancels pending work.
  • Online status: listeners are removed; request failures are handled independently.
  • Media query: changing the query removes the previous subscription.
  • Storage: existing values are preserved; invalid JSON and failed writes are surfaced.
  • In view: changing the target disconnects the old observer; unavailable APIs have a safe fallback.

You don't need a hook for every repeated expression.

You need one when several components should rely on the same behavior—and you want that behavior's lifecycle, assumptions, and limitations maintained in one place.

What behavior have you copied between React components that would make a good custom hook?

Top comments (0)