Skip to main content

Search

Search problems, hooks, and pages

Custom Hooks

11 of 11

Battle-tested reusable hooks asked about in frontend interviews. Each one ships a live demo, the full TypeScript implementation, real usage, and the gotcha interviewers probe for. The source mirrors importable hooks under src/hooks/.

Wraps a boolean useState and returns toggle / setOn / setOff callbacks that keep the same identity across renders (memoised with useCallback). Because the references never change, you can pass them to React.memo children without triggering pointless re-renders.

const { value, toggle, setOn, setOff } = useToggle(initial)

Live demo

OFF

Implementation

import { useCallback, useState } from 'react';

export default function useToggle(initial = false) {
  const [value, setValue] = useState(initial);

  const toggle = useCallback(() => setValue((v) => !v), []);
  const setOn = useCallback(() => setValue(true), []);
  const setOff = useCallback(() => setValue(false), []);

  return { value, toggle, setOn, setOff, setValue } as const;
}

Usage

const { value: isOpen, toggle, setOff } = useToggle();

<button onClick={toggle}>Toggle</button>
{isOpen && <Modal onClose={setOff} />}

When to use it

  • Modal / drawer / accordion open state
  • Show-more / show-less sections
  • Boolean feature flags in local UI state

Warning

Watch out

Returning a bare setter pair without useCallback recreates the functions every render , fine for plain DOM, but it defeats React.memo on children.

Writes text via the async Clipboard API and flips a `copied` flag that auto-resets after a delay. Returns false when the API is unavailable or the write is rejected (e.g. insecure context).

const { copied, copy } = useCopyToClipboard(resetDelay?)

Live demo

npm install react-virtuoso

Implementation

import { useCallback, useState } from 'react';

export default function useCopyToClipboard(resetDelay = 1500) {
  const [copied, setCopied] = useState(false);

  const copy = useCallback(
    async (text: string): Promise<boolean> => {
      if (typeof navigator === 'undefined' || !navigator.clipboard) return false;
      try {
        await navigator.clipboard.writeText(text);
        setCopied(true);
        setTimeout(() => setCopied(false), resetDelay);
        return true;
      } catch {
        setCopied(false);
        return false;
      }
    },
    [resetDelay]
  );

  return { copied, copy } as const;
}

Usage

const { copied, copy } = useCopyToClipboard();

<button onClick={() => copy('npm i')}>
  {copied ? 'Copied!' : 'Copy'}
</button>

When to use it

  • Copy code snippets / install commands
  • "Copy link" share buttons
  • Copy API keys or generated tokens

Warning

Watch out

navigator.clipboard is undefined on http (non-secure) origins and some in-app webviews , always handle the false return rather than assuming success.

Behaves like useState but reads its initial value from localStorage and writes every update back. A storage event listener keeps the value in sync when the same key changes in another tab.

const [value, setValue] = useLocalStorage(key, defaultValue)

Live demo

Reload the page or open another tab, the value sticks. Stored under custom-hooks-demo-name.

Implementation

import { useState, useEffect } from 'react';

export default function useLocalStorage<ValueType>(key: string, defaultValue: ValueType) {
  const [value, setValue] = useState(() => {
    const stored = typeof window !== 'undefined' ? localStorage.getItem(key) : null;
    return stored === null ? defaultValue : JSON.parse(stored);
  });

  useEffect(() => {
    const listener = (e: StorageEvent) => {
      if (e.storageArea === localStorage && e.key === key) {
        setValue(e.newValue ? JSON.parse(e.newValue) : e.newValue);
      }
    };
    window.addEventListener('storage', listener);
    return () => window.removeEventListener('storage', listener);
  }, [key, defaultValue]);

  const setValueInLocalStorage = (newValue: ValueType) => {
    setValue((current: any) => {
      const result = typeof newValue === 'function' ? newValue(current) : newValue;
      localStorage.setItem(key, JSON.stringify(result));
      return result;
    });
  };

  return [value, setValueInLocalStorage];
}

Usage

const [theme, setTheme] = useLocalStorage('theme', 'light');

<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
  {theme}
</button>

When to use it

  • Theme / language preferences
  • Persisting form drafts
  • Remembering dismissed banners or onboarding state

Warning

Watch out

The initial value is read from localStorage during the first render (the useState lazy initialiser) , that only survives server-side rendering because of the `typeof window` guard. Remove the guard and Next.js throws "localStorage is not defined" on the server.

Returns a value that only updates after `delay`ms of silence. Each change restarts the timer, so a burst of updates (fast typing) collapses into one final update after the user pauses , the canonical "search as you type" optimisation.

const debounced = useDebounce(value, delay?)

Live demo

Live: ,

Debounced (500ms): ,

Implementation

import { useEffect, useState } from 'react';

export default function useDebounce<T>(value: T, delay = 500): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

Usage

const [query, setQuery] = useState('');
const debounced = useDebounce(query, 400);

useEffect(() => { if (debounced) search(debounced); }, [debounced]);

When to use it

  • Search-as-you-type API calls
  • Validating an input only after the user pauses
  • Autosaving a draft after editing stops

Warning

Watch out

Debounce waits for quiet , if the value never stops changing, it never fires. For a guaranteed cadence under continuous change, use throttle instead.

Returns a copy of the value that changes at most once every `interval`ms. Unlike debounce (which waits for a pause), throttle emits on a fixed cadence even while the source keeps changing , ideal for scroll, mousemove, and resize.

const throttled = useThrottle(value, interval?)

Live demo

Live clicks: 0

Throttled (≤1/sec): 0

Implementation

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

export default function useThrottle<T>(value: T, interval = 500): T {
  const [throttled, setThrottled] = useState(value);
  const lastRun = useRef(Date.now());

  useEffect(() => {
    const elapsed = Date.now() - lastRun.current;
    if (elapsed >= interval) {
      lastRun.current = Date.now();
      setThrottled(value);
    } else {
      const id = setTimeout(() => {
        lastRun.current = Date.now();
        setThrottled(value);
      }, interval - elapsed);
      return () => clearTimeout(id);
    }
  }, [value, interval]);

  return throttled;
}

Usage

const [scrollY, setScrollY] = useState(0);
const throttledY = useThrottle(scrollY, 200);
// throttledY updates ~5x/sec no matter how fast you scroll

When to use it

  • Scroll-position-driven UI (progress bars, sticky headers)
  • Mousemove / drag tracking
  • Rate-limiting high-frequency analytics events

Warning

Watch out

Debounce vs throttle is a classic interview trap: debounce = "wait until it stops", throttle = "at most N per second". Reaching for the wrong one makes continuous events feel laggy or spammy.

Answers "what was this value last render?" using a ref written inside an effect. The trick: effects run AFTER render, so while rendering, the ref still holds the previous value , no extra state, no extra re-renders.

const prev = usePrevious(value)

Live demo

now: 0 · prev: ,

Implementation

import { useEffect, useRef } from 'react';

export default function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

Usage

const prevCount = usePrevious(count);

<p>Now: {count} · was: {prevCount ?? '—'}</p>

When to use it

  • Detecting which prop changed in an effect
  • Animating based on the delta between renders
  • Comparing old vs new value before running side effects

Warning

Watch out

It returns undefined on the first render (there is no previous value yet) , always guard with a fallback.

Tracks window.innerWidth / innerHeight and updates on resize. Seeds to 0×0 on the server and syncs once on mount, so the first client render matches the server output and avoids a hydration mismatch.

const { width, height } = useWindowSize()

Live demo

0 × 0px

desktop (≥768)

Resize the browser window to watch it update.

Implementation

import { useEffect, useState } from 'react';

interface WindowSize { width: number; height: number; }

export default function useWindowSize(): WindowSize {
  const [size, setSize] = useState<WindowSize>({ width: 0, height: 0 });

  useEffect(() => {
    const onResize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
    onResize(); // sync once on mount
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return size;
}

Usage

const { width } = useWindowSize();
const isMobile = width > 0 && width < 768;

When to use it

  • JS-driven responsive logic beyond CSS media queries
  • Conditionally rendering desktop vs mobile components
  • Sizing canvases / charts to the viewport

Warning

Watch out

For real apps, prefer MUI useMediaQuery or CSS for layout. A raw resize listener fires very frequently , throttle it if the handler does anything expensive.

Returns a ref to attach to an element; the handler fires whenever a mousedown / touchstart lands outside it. The latest handler is stored in a ref so the document listener is attached only once.

const ref = useClickOutside(handler)

Live demo

Implementation

import { useEffect, useRef } from 'react';

export default function useClickOutside<T extends HTMLElement = HTMLElement>(
  handler: (event: MouseEvent | TouchEvent) => void
) {
  const ref = useRef<T>(null);
  const handlerRef = useRef(handler);
  handlerRef.current = handler;

  useEffect(() => {
    const listener = (event: MouseEvent | TouchEvent) => {
      const el = ref.current;
      if (!el || el.contains(event.target as Node)) return;
      handlerRef.current(event);
    };
    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);
    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, []);

  return ref;
}

Usage

const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));

{open && <div ref={ref}>Dropdown…</div>}

When to use it

  • Closing dropdowns / popovers / menus
  • Dismissing modals on backdrop click
  • Exiting an inline edit field

Warning

Watch out

Use mousedown, not click , by the time a click fires, focus may already have moved and the menu may have re-rendered, causing the toggle button to immediately reopen what you just closed.

Reports whether the browser is online. Seeds from navigator.onLine (guarded for SSR) and stays in sync via the window online / offline events. Drive offline banners or pause network work with it.

const isOnline = useOnlineStatus()

Live demo

Online

Toggle “Offline” in DevTools → Network (or turn off Wi-Fi) to see it flip.

Implementation

import { useEffect, useState } from 'react';

export default function useOnlineStatus(): boolean {
  const [online, setOnline] = useState(() =>
    typeof navigator !== 'undefined' ? navigator.onLine : true
  );

  useEffect(() => {
    const goOnline = () => setOnline(true);
    const goOffline = () => setOnline(false);
    window.addEventListener('online', goOnline);
    window.addEventListener('offline', goOffline);
    return () => {
      window.removeEventListener('online', goOnline);
      window.removeEventListener('offline', goOffline);
    };
  }, []);

  return online;
}

Usage

const isOnline = useOnlineStatus();

{!isOnline && <Banner>You are offline, changes will sync later.</Banner>}

When to use it

  • Offline banners / toasts
  • Pausing polling or queuing mutations while offline
  • Disabling submit buttons that need the network

Warning

Watch out

navigator.onLine only means "connected to a network", not "the internet works". A captive-portal or dead Wi-Fi still reports online , for true reachability you must ping a real endpoint.

Attaches an IntersectionObserver to the returned ref and reports whether the element is intersecting. With freezeOnceVisible it disconnects after the first reveal , perfect for lazy-loading and one-shot animations.

const { ref, isIntersecting } = useIntersectionObserver(options?)

Live demo

Status:
scrolled away

Scroll me into / out of view

Implementation

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

export default function useIntersectionObserver<T extends HTMLElement = HTMLElement>(
  options: IntersectionObserverInit & { freezeOnceVisible?: boolean } = {}
) {
  const { freezeOnceVisible = false, ...observerInit } = options;
  const ref = useRef<T>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);
  const frozen = useRef(false);

  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === 'undefined' || frozen.current) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
      if (entry.isIntersecting && freezeOnceVisible) {
        frozen.current = true;
        observer.disconnect();
      }
    }, observerInit);

    observer.observe(el);
    return () => observer.disconnect();
  }, [freezeOnceVisible, observerInit.root, observerInit.rootMargin, observerInit.threshold]);

  return { ref, isIntersecting } as const;
}

Usage

const { ref, isIntersecting } = useIntersectionObserver({ freezeOnceVisible: true });

<img ref={ref} src={isIntersecting ? realSrc : placeholder} />

When to use it

  • Lazy-loading images / components on scroll
  • Reveal-on-scroll animations
  • Infinite scroll (observe a sentinel at the list end)
  • Impression tracking for analytics

Warning

Watch out

Passing a fresh options object every render re-creates the observer each time. Pass primitive, stable values (or memoise) , here we key the effect on the individual init fields, not the object identity.

Fetches a URL and returns { data, loading, error }. It re-runs when the URL changes and aborts the in-flight request on cleanup, so a fast re-render or unmount can never set state from a stale response (avoiding the classic race + memory-leak warning).

const { data, loading, error } = useFetch<T>(url, options?)

Live demo

/todos/1

Loading…

Implementation

import { useEffect, useState } from 'react';

interface FetchState<T> { data: T | null; loading: boolean; error: Error | null; }

export default function useFetch<T = unknown>(url: string, options?: RequestInit): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ data: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { ...options, signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((error: Error) => {
        if (error.name !== 'AbortError') setState({ data: null, loading: false, error });
      });

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

  return state;
}

Usage

const { data, loading, error } = useFetch<User>('/api/me');

if (loading) return <Spinner />;
if (error) return <Error msg={error.message} />;
return <Profile user={data!} />;

When to use it

  • Simple GET requests tied to a component
  • Re-fetching when an id / query param changes
  • Learning the fetch + AbortController pattern before reaching for React Query

Warning

Watch out

For production prefer React Query / SWR , they add caching, dedupe, retries and revalidation. The abort on cleanup is the non-negotiable part: without it, two quick URL changes can let the slower response overwrite the newer one.