ESC

Type to search the knowledge base.

useCopyToClipboard Hook

Copy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.

beginner6 min read
  • react
  • hooks
  • clipboard
  • interview

“Copy link” that silently does nothing on http:// is a permissions bug, not a React bug. useCopyToClipboard wraps navigator.clipboard.writeText, falls back to a hidden <textarea> plus document.execCommand('copy') when the Clipboard API is missing, and exposes a short-lived isCopied flag so the button can read “Copied.”

The Clipboard API is promise-based and requires a secure context (HTTPS or localhost). It is also often gated on a user gesture and on clipboard-write permission. Feature-detect; do not sniff the UA (Feature Detection vs UA Sniffing).

execCommand is deprecated. It still saves you in older WebViews. Treat it as a fallback, not as the implementation you lead with.

  • Try navigator.clipboard.writeText(text) first. Await it. Catch permission and insecure-context failures.
  • Fallback: hidden textarea, select(), document.execCommand('copy'), remove the node.
  • isCopied is transient. Start a timeout, setIsCopied(false) later, clearTimeout on unmount or a second copy.
  • Call copy from a click handler, not from an effect. Browsers can deny clipboard writes outside a gesture.
  • Do not throw out of the hook’s render. Return { copy, isCopied, error } and let the button decide.

Clipboard API first, then a hidden textarea fallback

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

function fallbackCopy(text) {
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.setAttribute('readonly', '');
  ta.setAttribute('aria-hidden', 'true');
  ta.style.position = 'fixed';
  ta.style.top = '0';
  ta.style.left = '-9999px';
  document.body.appendChild(ta);
  ta.select();
  ta.setSelectionRange(0, ta.value.length);
  const ok = document.execCommand('copy');
  document.body.removeChild(ta);
  if (!ok) {
    throw new Error('execCommand copy failed');
  }
}

function useCopyToClipboard({ resetMs = 2000 } = {}) {
  const [isCopied, setIsCopied] = useState(false);
  const [error, setError] = useState(null);
  const resetMsRef = useRef(resetMs);

  useEffect(() => {
    resetMsRef.current = resetMs;
  }, [resetMs]);

  useEffect(() => {
    if (!isCopied) return undefined;
    const id = setTimeout(() => setIsCopied(false), resetMsRef.current);
    return () => clearTimeout(id);
  }, [isCopied]);

  async function copy(text) {
    if (typeof text !== 'string') {
      setError(new TypeError('copy() expects a string'));
      setIsCopied(false);
      return false;
    }

    setError(null);
    try {
      const canClipboard =
        typeof navigator !== 'undefined' &&
        typeof navigator.clipboard?.writeText === 'function' &&
        (typeof window === 'undefined' || window.isSecureContext);
      if (canClipboard) {
        await navigator.clipboard.writeText(text);
      } else {
        fallbackCopy(text);
      }
      setIsCopied(true);
      return true;
    } catch (err) {
      setError(err);
      setIsCopied(false);
      return false;
    }
  }

  return { copy, isCopied, error };
}
function CopyLink({ url }) {
  const { copy, isCopied, error } = useCopyToClipboard();

  return (
    <button type="button" onClick={() => copy(url)}>
      {isCopied ? 'Copied' : 'Copy link'}
      {error ? <span role="alert">Could not copy</span> : null}
    </button>
  );
}

Procedure for the happy path:

  1. User clicks. You are inside a user gesture.
  2. If navigator.clipboard.writeText exists, await it.
  3. On success, isCopied = true. An effect starts resetMs and flips it back. Cleanup clears that timer if they copy again or unmount.
  4. On failure, try nothing else if the Clipboard API existed and threw (permission). Optional: fall through to execCommand only when the API is missing, not when it denied. Denials should surface.

A stricter fallback policy:

const canUseClipboard =
  typeof navigator !== 'undefined' &&
  window.isSecureContext &&
  typeof navigator.clipboard?.writeText === 'function';

if (canUseClipboard) {
  await navigator.clipboard.writeText(text);
} else {
  fallbackCopy(text);
}

window.isSecureContext is the boolean to name. http://192.168.x.x on a phone is a common fail: the API exists, the promise rejects.

Why the textarea fallback looks like that

Detail Reason
readonly Some iOS versions zoom or keyboard-open an editable field
position: fixed; left: -9999px Off-screen without display: none (which can make select() fail)
setSelectionRange iOS needs an explicit range, not only select()
Remove the node Do not leak a field into the a11y tree or tab order
aria-hidden Belt if removal is delayed

document.execCommand('copy') returns a boolean. It requires the textarea to be in the document and selected. It can still fail in sandboxed iframes (allow-modals / clipboard permissions). Throw and let error drive UI.

Do not use window.prompt as a fallback in an interview unless you are joking.

Permissions and policy

  • Secure context. HTTPS or localhost.
  • User gesture. Some engines require the writeText call to be chained from the click without an unrelated await in between. If you await confirm() first, you may lose the gesture. Keep the clipboard call close to the handler.
  • Permissions-Policy / Feature-Policy. An embed can disable clipboard-write. Detection: catch the error, do not parse policy headers in JS.
  • Not for secrets. Anything you copy sits in a shared OS clipboard. Do not copy access tokens into it as a “debug helper” in production UI.

navigator.clipboard.readText() is a different, more locked-down permission. This hook is write-only.

Footguns

Creating the hook’s copy in a way that goes stale. copy closes over nothing important in the snippet (it uses setters, which are stable). If you add options, keep them in refs so copy can be wrapped in useCallback for memoized children.

Reset timeout without cleanup. Copy, unmount 200 ms later, setIsCopied on a dead instance. Clear the timeout. Copy twice quickly: the first timeout should not clear the second success; depending on [isCopied] retriggers the effect, which is enough if you set true → true (no re-run). Bump a nonce if you need the timer to restart on repeated success:

const [token, setToken] = useState(0);
// on success: setIsCopied(true); setToken((n) => n + 1);
useEffect(() => { /* timeout */ }, [token]);

Putting text in state. The hook does not need to remember what it copied. The button already has url.

SSR. navigator is missing. The hook should still run (rules of hooks). copy only runs on click, on the client.

HTML vs text. writeText is plain text. Rich copy needs ClipboardItem and image/png / text/html blobs. Out of scope unless asked.

Interview out-loud answer

I would call navigator.clipboard.writeText from the click handler and await it. That API needs a secure context and often a user gesture; I feature-detect and I catch permission errors instead of letting them throw during render. If the API is missing, I copy through a hidden textarea and document.execCommand('copy'), then remove the node. isCopied flips true and a timeout with cleanup resets it so the button label is transient. I would not sniff the user agent to decide whether copy works.

Further reading

Related guides