"Copy to clipboard" buttons are everywhere — copy a share link, an API key, a code snippet — and they all lean on the same browser API: navigator.clipboard.writeText, which is async and can reject (denied permission, insecure context, no clipboard at all). useCopyToClipboard wraps that into a clean pair: a copy(text) function you call on click, and the last-copied text you can use to flip a button to "Copied!".
Implement useCopyToClipboard(). It returns [copiedText, copy]. copiedText is the last successfully copied string (or null before any copy, and null again after a failure). copy(text) is async: it writes via the Clipboard API, resolves to true and stores the text on success, or false on failure or when the API is unavailable.
function useCopyToClipboard() {
// returns [copiedText, copy]
// copiedText: string | null
// copy: (text: string) => Promise<boolean>
}
const [copied, copy] = useCopyToClipboard();
<button onClick={() => copy('https://uiready.dev')}>
{copied ? 'Copied!' : 'Copy link'}
</button>
const [, copy] = useCopyToClipboard();
const ok = await copy(apiKey);
if (!ok) toast('Copy failed — select and press ⌘C');
writeText returns a promise that rejects on denied permission or an insecure (non-HTTPS) context. Wrap it in try/catch.navigator.clipboard is undefined in old or insecure contexts; return false instead of throwing.copiedText when the write resolves; clear it to null on failure so the UI never lies about what's on the clipboard.copy — wrap it in useCallback so it can be a dependency or a memoized prop.You'll wrap the async navigator.clipboard.writeText in a stable copy function that feature-detects, awaits the write, and updates a copiedText state to true/false outcomes — never crashing on a rejection.
The Clipboard API is deceptively simple to call and easy to get wrong. writeText is asynchronous and rejects in exactly the situations you'll hit in production: the user denied clipboard permission, the page isn't served over HTTPS, or the browser doesn't expose navigator.clipboard at all. A naive call throws an unhandled rejection and your "Copy" button silently does nothing. The hook's job is to make copying safe — always resolve to a clear boolean, and expose what was last copied so the UI can show feedback.
Two moving parts: an action and a memory. The action is copy(text) — feature-detect, try to write, report success or failure. The memory is copiedText state — the last thing that actually landed on the clipboard, or null if the most recent attempt failed. Success writes the memory; failure erases it, so the button's "Copied!" state can never outlive a real copy.
The tempting version calls writeText and assumes it worked:
function useCopyToClipboardNaive() {
const [copiedText, setCopiedText] = useState(null);
const copy = (text) => {
navigator.clipboard.writeText(text); // unawaited, unguarded
setCopiedText(text); // claims success unconditionally
};
return [copiedText, copy];
}
Three failures. It never awaits, so copiedText is set before the write resolves — and set even if the write later rejects, so the UI lies. It throws a TypeError in any context where navigator.clipboard is undefined. And an unhandled promise rejection surfaces in the console when permission is denied. Copying is a fallible async operation and must be treated as one.
const { useState, useCallback } = require('react');
function useCopyToClipboard() {
const [copiedText, setCopiedText] = useState(null);
const copy = useCallback(async (text) => {
// Feature-detect: undefined in old browsers / insecure contexts.
if (!navigator?.clipboard) {
setCopiedText(null);
return false;
}
try {
await navigator.clipboard.writeText(text);
setCopiedText(text); // record only what actually landed
return true;
} catch {
setCopiedText(null); // a failed write must not claim success
return false;
}
}, []);
return [copiedText, copy];
}
module.exports = { useCopyToClipboard };
copy is async and wrapped in useCallback([]) for a stable identity. It first feature-detects navigator?.clipboard and bails with false when it's missing. Otherwise it awaits writeText, and only after the promise resolves does it set copiedText to the written text and return true. If the promise rejects — denied permission, insecure context — the catch sets copiedText back to null and returns false. Every path returns a boolean and updates state to match reality, so the caller can trust both the return value and copiedText.
Render the hook, then click a button that calls copy('https://uiready.dev') where clipboard access is granted:
copiedText is null; the button shows "Copy".copy runs, sees navigator.clipboard exists, awaits writeText('https://uiready.dev').setCopiedText('https://uiready.dev'), returns true. The component re-renders; the button now shows "Copied!".copy(secret) awaits writeText, which rejects; the catch runs setCopiedText(null), returns false. The button falls back to "Copy" and the app can show an error — the state never falsely claims the secret was copied.writeText — setting copiedText before the promise resolves records a success that may not happen. Await first.navigator.clipboard is undefined over HTTP or in old browsers; touching .writeText throws. Guard it.copiedText = null and return false, or the button lies.copy — recreating it each render breaks memoized children and effect deps; wrap in useCallback.copiedText with a setTimeout that clears it after ~2s makes the "Copied!" state revert on its own.document.execCommand('copy') fallback — for very old browsers, a hidden <textarea> + execCommand is the legacy path when navigator.clipboard is absent.navigator.clipboard.readText() (behind a permission prompt) powers "paste" features and is the natural companion hook.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
"Copy to clipboard" buttons are everywhere — copy a share link, an API key, a code snippet — and they all lean on the same browser API: navigator.clipboard.writeText, which is async and can reject (denied permission, insecure context, no clipboard at all). useCopyToClipboard wraps that into a clean pair: a copy(text) function you call on click, and the last-copied text you can use to flip a button to "Copied!".
Implement useCopyToClipboard(). It returns [copiedText, copy]. copiedText is the last successfully copied string (or null before any copy, and null again after a failure). copy(text) is async: it writes via the Clipboard API, resolves to true and stores the text on success, or false on failure or when the API is unavailable.
function useCopyToClipboard() {
// returns [copiedText, copy]
// copiedText: string | null
// copy: (text: string) => Promise<boolean>
}
const [copied, copy] = useCopyToClipboard();
<button onClick={() => copy('https://uiready.dev')}>
{copied ? 'Copied!' : 'Copy link'}
</button>
const [, copy] = useCopyToClipboard();
const ok = await copy(apiKey);
if (!ok) toast('Copy failed — select and press ⌘C');
writeText returns a promise that rejects on denied permission or an insecure (non-HTTPS) context. Wrap it in try/catch.navigator.clipboard is undefined in old or insecure contexts; return false instead of throwing.copiedText when the write resolves; clear it to null on failure so the UI never lies about what's on the clipboard.copy — wrap it in useCallback so it can be a dependency or a memoized prop.You'll wrap the async navigator.clipboard.writeText in a stable copy function that feature-detects, awaits the write, and updates a copiedText state to true/false outcomes — never crashing on a rejection.
The Clipboard API is deceptively simple to call and easy to get wrong. writeText is asynchronous and rejects in exactly the situations you'll hit in production: the user denied clipboard permission, the page isn't served over HTTPS, or the browser doesn't expose navigator.clipboard at all. A naive call throws an unhandled rejection and your "Copy" button silently does nothing. The hook's job is to make copying safe — always resolve to a clear boolean, and expose what was last copied so the UI can show feedback.
Two moving parts: an action and a memory. The action is copy(text) — feature-detect, try to write, report success or failure. The memory is copiedText state — the last thing that actually landed on the clipboard, or null if the most recent attempt failed. Success writes the memory; failure erases it, so the button's "Copied!" state can never outlive a real copy.
The tempting version calls writeText and assumes it worked:
function useCopyToClipboardNaive() {
const [copiedText, setCopiedText] = useState(null);
const copy = (text) => {
navigator.clipboard.writeText(text); // unawaited, unguarded
setCopiedText(text); // claims success unconditionally
};
return [copiedText, copy];
}
Three failures. It never awaits, so copiedText is set before the write resolves — and set even if the write later rejects, so the UI lies. It throws a TypeError in any context where navigator.clipboard is undefined. And an unhandled promise rejection surfaces in the console when permission is denied. Copying is a fallible async operation and must be treated as one.
const { useState, useCallback } = require('react');
function useCopyToClipboard() {
const [copiedText, setCopiedText] = useState(null);
const copy = useCallback(async (text) => {
// Feature-detect: undefined in old browsers / insecure contexts.
if (!navigator?.clipboard) {
setCopiedText(null);
return false;
}
try {
await navigator.clipboard.writeText(text);
setCopiedText(text); // record only what actually landed
return true;
} catch {
setCopiedText(null); // a failed write must not claim success
return false;
}
}, []);
return [copiedText, copy];
}
module.exports = { useCopyToClipboard };
copy is async and wrapped in useCallback([]) for a stable identity. It first feature-detects navigator?.clipboard and bails with false when it's missing. Otherwise it awaits writeText, and only after the promise resolves does it set copiedText to the written text and return true. If the promise rejects — denied permission, insecure context — the catch sets copiedText back to null and returns false. Every path returns a boolean and updates state to match reality, so the caller can trust both the return value and copiedText.
Render the hook, then click a button that calls copy('https://uiready.dev') where clipboard access is granted:
copiedText is null; the button shows "Copy".copy runs, sees navigator.clipboard exists, awaits writeText('https://uiready.dev').setCopiedText('https://uiready.dev'), returns true. The component re-renders; the button now shows "Copied!".copy(secret) awaits writeText, which rejects; the catch runs setCopiedText(null), returns false. The button falls back to "Copy" and the app can show an error — the state never falsely claims the secret was copied.writeText — setting copiedText before the promise resolves records a success that may not happen. Await first.navigator.clipboard is undefined over HTTP or in old browsers; touching .writeText throws. Guard it.copiedText = null and return false, or the button lies.copy — recreating it each render breaks memoized children and effect deps; wrap in useCallback.copiedText with a setTimeout that clears it after ~2s makes the "Copied!" state revert on its own.document.execCommand('copy') fallback — for very old browsers, a hidden <textarea> + execCommand is the legacy path when navigator.clipboard is absent.navigator.clipboard.readText() (behind a permission prompt) powers "paste" features and is the natural companion hook.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.