A copy button writes a fixed snippet to the system clipboard and briefly confirms a successful write. Build the interaction in React with one boolean state value and a reset timer that restarts on every successful click.
Implement the default App component in App.tsx. It receives no props and renders the fixed snippet npm install uiready beside the button.
Copy.npm install uiready; the button turns green and reads Copied!.navigator.clipboard.writeText(SNIPPET) before setting copied to true.copied class and button label from a useState boolean.useRef, then clear it before scheduling the next reset.styles.css define the required initial appearance.You'll copy the text through the async Clipboard API, then hold one copied boolean that drives both the class and the label, resetting it after 2 seconds with a timer you can cancel.
A copy button has two jobs: put the text on the clipboard, and reassure the user it worked. The reassurance is temporary — it should appear on success and disappear a couple of seconds later. The write is asynchronous (navigator.clipboard.writeText returns a Promise), and the "disappear later" part is a timer that must survive impatient, repeated clicks.
copied is a single boolean. The green button and the Copied! text are not two things to sync — they're that one boolean, rendered two ways. The only real subtlety is the timer: each click starts a fresh 2s countdown, so you must cancel the previous one first, otherwise an old timer fires mid-window and reverts the label too early.
A common first try fires the timer without tracking it, and ignores that the write is async:
function handleCopy() {
navigator.clipboard.writeText('npm install uiready'); // not awaited
setCopied(true);
setTimeout(() => setCopied(false), 2000); // timer id thrown away
}
It mostly looks right. But two things bite. First, copied flips to true whether or not the write actually succeeded — if the clipboard call rejects, you've lied to the user. Second, every click schedules a new independent timeout while the old ones keep running; click twice quickly and the first timer reverts the label one second into your second confirmation. You need to await the write and to hold the timer id so you can cancel it.
import { useState, useRef } from 'react';
import './styles.css';
const SNIPPET = 'npm install uiready';
export default function App() {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
async function handleCopy() {
await navigator.clipboard.writeText(SNIPPET);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 2000);
}
return (
<main className="container">
<h1>Copy to Clipboard Button</h1>
<div className="snippet">
<code className="code">{SNIPPET}</code>
<button
type="button"
className={copied ? 'copy copied' : 'copy'}
onClick={handleCopy}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</main>
);
}
copied is the single source of truth. handleCopy awaits the write, so copied only turns true after the clipboard genuinely accepted the text. The useRef holds the pending timeout across renders; clearTimeout(timerRef.current) cancels whatever was scheduled before starting a fresh 2s reset, so rapid clicks extend the window cleanly instead of fighting each other. The class and the label both read copied — nothing can drift.
copied = false → class copy, label Copy.await writeText(...) resolves → setCopied(true) → class copy copied (green), label Copied!; the previous timer (if any) is cleared and a new 2s timer starts.setCopied(false) → back to Copy.Now click at t=0 and again at t=1000. The second handler clears the first timeout before it can reset the state. Its replacement fires at t=3000, so Copied! stays visible for two full seconds after the latest click.
copied before the Promise resolves can show Copied! even when the copy failed. Await it (or .then), and consider a catch for the denied-permission case.useRef (not a plain variable, which resets each render) lets you clearTimeout it so overlapping clicks don't revert early.copied keeps them in lock-step; tracking a separate label string invites them to disagree.navigator.clipboard; fall back to a hidden textarea plus document.execCommand('copy').writeText rejects if permission is denied; surface a Copy failed state instead of a false Copied!.useCopyToClipboard(text) hook so any button can reuse it.This version uses a reducer for the two semantic transitions while a ref still owns the browser timer. The button DOM stays identical.
import { useReducer, useRef } from 'react';
import './styles.css';
const SNIPPET = 'npm install uiready';
function reducer(_copied: boolean, action: 'copied' | 'reset'): boolean {
return action === 'copied';
}
export default function App() {
const [copied, dispatch] = useReducer(reducer, false);
const timerRef = useRef<number | undefined>(undefined);
async function handleCopy() {
await navigator.clipboard.writeText(SNIPPET);
dispatch('copied');
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => dispatch('reset'), 2000);
}
return (
<main className="container">
<h1>Copy to Clipboard Button</h1>
<div className="snippet">
<code className="code">{SNIPPET}</code>
<button
type="button"
className={copied ? 'copy copied' : 'copy'}
onClick={handleCopy}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A copy button writes a fixed snippet to the system clipboard and briefly confirms a successful write. Build the interaction in React with one boolean state value and a reset timer that restarts on every successful click.
Implement the default App component in App.tsx. It receives no props and renders the fixed snippet npm install uiready beside the button.
Copy.npm install uiready; the button turns green and reads Copied!.navigator.clipboard.writeText(SNIPPET) before setting copied to true.copied class and button label from a useState boolean.useRef, then clear it before scheduling the next reset.styles.css define the required initial appearance.You'll copy the text through the async Clipboard API, then hold one copied boolean that drives both the class and the label, resetting it after 2 seconds with a timer you can cancel.
A copy button has two jobs: put the text on the clipboard, and reassure the user it worked. The reassurance is temporary — it should appear on success and disappear a couple of seconds later. The write is asynchronous (navigator.clipboard.writeText returns a Promise), and the "disappear later" part is a timer that must survive impatient, repeated clicks.
copied is a single boolean. The green button and the Copied! text are not two things to sync — they're that one boolean, rendered two ways. The only real subtlety is the timer: each click starts a fresh 2s countdown, so you must cancel the previous one first, otherwise an old timer fires mid-window and reverts the label too early.
A common first try fires the timer without tracking it, and ignores that the write is async:
function handleCopy() {
navigator.clipboard.writeText('npm install uiready'); // not awaited
setCopied(true);
setTimeout(() => setCopied(false), 2000); // timer id thrown away
}
It mostly looks right. But two things bite. First, copied flips to true whether or not the write actually succeeded — if the clipboard call rejects, you've lied to the user. Second, every click schedules a new independent timeout while the old ones keep running; click twice quickly and the first timer reverts the label one second into your second confirmation. You need to await the write and to hold the timer id so you can cancel it.
import { useState, useRef } from 'react';
import './styles.css';
const SNIPPET = 'npm install uiready';
export default function App() {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
async function handleCopy() {
await navigator.clipboard.writeText(SNIPPET);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 2000);
}
return (
<main className="container">
<h1>Copy to Clipboard Button</h1>
<div className="snippet">
<code className="code">{SNIPPET}</code>
<button
type="button"
className={copied ? 'copy copied' : 'copy'}
onClick={handleCopy}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</main>
);
}
copied is the single source of truth. handleCopy awaits the write, so copied only turns true after the clipboard genuinely accepted the text. The useRef holds the pending timeout across renders; clearTimeout(timerRef.current) cancels whatever was scheduled before starting a fresh 2s reset, so rapid clicks extend the window cleanly instead of fighting each other. The class and the label both read copied — nothing can drift.
copied = false → class copy, label Copy.await writeText(...) resolves → setCopied(true) → class copy copied (green), label Copied!; the previous timer (if any) is cleared and a new 2s timer starts.setCopied(false) → back to Copy.Now click at t=0 and again at t=1000. The second handler clears the first timeout before it can reset the state. Its replacement fires at t=3000, so Copied! stays visible for two full seconds after the latest click.
copied before the Promise resolves can show Copied! even when the copy failed. Await it (or .then), and consider a catch for the denied-permission case.useRef (not a plain variable, which resets each render) lets you clearTimeout it so overlapping clicks don't revert early.copied keeps them in lock-step; tracking a separate label string invites them to disagree.navigator.clipboard; fall back to a hidden textarea plus document.execCommand('copy').writeText rejects if permission is denied; surface a Copy failed state instead of a false Copied!.useCopyToClipboard(text) hook so any button can reuse it.This version uses a reducer for the two semantic transitions while a ref still owns the browser timer. The button DOM stays identical.
import { useReducer, useRef } from 'react';
import './styles.css';
const SNIPPET = 'npm install uiready';
function reducer(_copied: boolean, action: 'copied' | 'reset'): boolean {
return action === 'copied';
}
export default function App() {
const [copied, dispatch] = useReducer(reducer, false);
const timerRef = useRef<number | undefined>(undefined);
async function handleCopy() {
await navigator.clipboard.writeText(SNIPPET);
dispatch('copied');
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => dispatch('reset'), 2000);
}
return (
<main className="container">
<h1>Copy to Clipboard Button</h1>
<div className="snippet">
<code className="code">{SNIPPET}</code>
<button
type="button"
className={copied ? 'copy copied' : 'copy'}
onClick={handleCopy}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.