useCopyToClipboard Hook
Copy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- 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.isCopiedis transient. Start a timeout,setIsCopied(false)later,clearTimeouton unmount or a second copy.- Call
copyfrom 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:
- User clicks. You are inside a user gesture.
- If
navigator.clipboard.writeTextexists, await it. - On success,
isCopied = true. An effect startsresetMsand flips it back. Cleanup clears that timer if they copy again or unmount. - On failure, try nothing else if the Clipboard API existed and threw (permission). Optional: fall through to
execCommandonly 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
writeTextcall to be chained from the click without an unrelatedawaitin between. If youawait 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.writeTextfrom 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 anddocument.execCommand('copy'), then remove the node.isCopiedflips 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
- MDN:
Clipboard.writeText() - MDN:
navigator.clipboard - MDN:
isSecureContext - MDN:
Document.execCommand(legacy fallback) - web.dev: Unblocking clipboard access
Related on this site
- Feature Detection vs UA Sniffing
- Rules of Hooks
- useEffect Fundamentals
- Custom Hooks Design
- Promisify a Node-Style Callback
- Implement Function Once
- Permissions Policy
- Selection and Range APIs
Related guides
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.
- useInterval HookA setInterval that sees the latest callback without restarting the timer when the callback identity changes. Dan Abramov’s ref pattern, named.