useClipboard Hook: Implementing Copy-to-Clipboard in React
Copying text to the clipboard is one of those features that feels simple until you need to handle browser compatibility, async operations, permissions, and user feedback all at once. Whether you're building a code snippet component, sharing URLs, or creating a password generator, clipboard operations are essential in modern web apps.
In this guide, we'll build a production-ready useClipboard custom hook that abstracts away the complexity of the Clipboard API. You'll learn how to handle async operations with proper error handling, provide meaningful feedback to users, and ensure cross-browser compatibility. By the end, you'll have a reusable hook that works across different browsers and handles edge cases gracefully.
Table of Contents
- Understanding the Clipboard API
- Basic Hook Implementation
- Adding User Feedback
- Error Handling & Fallbacks
- Advanced Features
- Real-World Implementation
- FAQ
Understanding the Clipboard API
The modern Clipboard API provides two main methods: navigator.clipboard.writeText() for writing and navigator.clipboard.readText() for reading. These methods are asynchronous and return promises, which means you need to handle them properly in React.
The key advantage of the Clipboard API over the older document.execCommand() approach is that it's cleaner, more maintainable, and provides better browser support. However, it requires the page to be served over HTTPS (except for localhost) and needs user interaction or appropriate permissions.
One important consideration: the Clipboard API is promise-based, which aligns perfectly with React's state management patterns. This makes it ideal for a custom hook that manages loading and success states.
Basic Hook Implementation
Let's start with the foundation. A basic useClipboard hook needs to manage three states: the loading state while the copy operation is pending, an error state if something goes wrong, and feedback about successful copies.
TypeScript Version
import { useState, useCallback } from 'react';
interface UseClipboardReturn {
copy: (text: string) => Promise<void>;
isLoading: boolean;
error: Error | null;
isCopied: boolean;
}
export function useClipboard(): UseClipboardReturn {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isCopied, setIsCopied] = useState(false);
const copy = useCallback(async (text: string) => {
if (!text) {
setError(new Error('Cannot copy empty text'));
return;
}
setIsLoading(true);
setError(null);
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
// Reset feedback after 2 seconds
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to copy');
setError(error);
setIsCopied(false);
} finally {
setIsLoading(false);
}
}, []);
return { copy, isLoading, error, isCopied };
}
JavaScript Version
import { useState, useCallback } from 'react';
export function useClipboard() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [isCopied, setIsCopied] = useState(false);
const copy = useCallback(async (text) => {
if (!text) {
setError(new Error('Cannot copy empty text'));
return;
}
setIsLoading(true);
setError(null);
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to copy');
setError(error);
setIsCopied(false);
} finally {
setIsLoading(false);
}
}, []);
return { copy, isLoading, error, isCopied };
}
Notice we're using useCallback to memoize the copy function. This prevents unnecessary re-renders of child components that receive this function as a prop and use shallow equality checks.
Adding User Feedback
The basic implementation provides feedback, but in real applications, you might want more control over the feedback duration or reset mechanism. Let's enhance it with configurable options.
Enhanced TypeScript Version
import { useState, useCallback, useRef } from 'react';
interface UseClipboardOptions {
timeout?: number;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
interface UseClipboardReturn {
copy: (text: string) => Promise<void>;
isLoading: boolean;
error: Error | null;
isCopied: boolean;
reset: () => void;
}
export function useClipboard(options: UseClipboardOptions = {}): UseClipboardReturn {
const { timeout = 2000, onSuccess, onError } = options;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
const reset = useCallback(() => {
setError(null);
setIsCopied(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const copy = useCallback(
async (text: string) => {
if (!text) {
const error = new Error('Cannot copy empty text');
setError(error);
onError?.(error);
return;
}
setIsLoading(true);
setError(null);
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
onSuccess?.();
// Clear previous timeout and set new one
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
}, timeout);
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to copy');
setError(error);
setIsCopied(false);
onError?.(error);
} finally {
setIsLoading(false);
}
},
[timeout, onSuccess, onError]
);
// Cleanup timeout on unmount
useCallback(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return { copy, isLoading, error, isCopied, reset };
}
Enhanced JavaScript Version
import { useState, useCallback, useRef } from 'react';
export function useClipboard(options = {}) {
const { timeout = 2000, onSuccess, onError } = options;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef();
const reset = useCallback(() => {
setError(null);
setIsCopied(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const copy = useCallback(
async (text) => {
if (!text) {
const error = new Error('Cannot copy empty text');
setError(error);
onError?.(error);
return;
}
setIsLoading(true);
setError(null);
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
onSuccess?.();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
}, timeout);
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to copy');
setError(error);
setIsCopied(false);
onError?.(error);
} finally {
setIsLoading(false);
}
},
[timeout, onSuccess, onError]
);
return { copy, isLoading, error, isCopied, reset };
}
This enhanced version introduces callbacks for success and error handling, allowing parent components to respond to clipboard operations without managing additional state.
Error Handling & Fallbacks
Not all browsers support the Clipboard API perfectly, and permissions can be tricky. Let's add a fallback mechanism for older browsers that still use document.execCommand().
TypeScript Version with Fallback
import { useState, useCallback, useRef } from 'react';
interface UseClipboardOptions {
timeout?: number;
useFallback?: boolean;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
interface UseClipboardReturn {
copy: (text: string) => Promise<void>;
isLoading: boolean;
error: Error | null;
isCopied: boolean;
reset: () => void;
isSupported: boolean;
}
export function useClipboard(options: UseClipboardOptions = {}): UseClipboardReturn {
const { timeout = 2000, useFallback = true, onSuccess, onError } = options;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
// Check if Clipboard API is supported
const isSupported = Boolean(
typeof window !== 'undefined' && navigator?.clipboard?.writeText
);
const fallbackCopy = (text: string): boolean => {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
return success;
} catch {
return false;
}
};
const reset = useCallback(() => {
setError(null);
setIsCopied(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const copy = useCallback(
async (text: string) => {
if (!text) {
const error = new Error('Cannot copy empty text');
setError(error);
onError?.(error);
return;
}
setIsLoading(true);
setError(null);
try {
let success = false;
if (isSupported) {
try {
await navigator.clipboard.writeText(text);
success = true;
} catch (clipboardError) {
// Clipboard API might fail due to permissions
if (useFallback) {
success = fallbackCopy(text);
} else {
throw clipboardError;
}
}
} else if (useFallback) {
success = fallbackCopy(text);
} else {
throw new Error('Clipboard API is not supported and fallback is disabled');
}
if (success) {
setIsCopied(true);
onSuccess?.();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
}, timeout);
} else {
throw new Error('Failed to copy text to clipboard');
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to copy');
setError(error);
setIsCopied(false);
onError?.(error);
} finally {
setIsLoading(false);
}
},
[isSupported, useFallback, timeout, onSuccess, onError]
);
return { copy, isLoading, error, isCopied, reset, isSupported };
}
JavaScript Version with Fallback
import { useState, useCallback, useRef } from 'react';
export function useClipboard(options = {}) {
const { timeout = 2000, useFallback = true, onSuccess, onError } = options;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef();
const isSupported = Boolean(
typeof window !== 'undefined' && navigator?.clipboard?.writeText
);
const fallbackCopy = (text) => {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
return success;
} catch {
return false;
}
};
const reset = useCallback(() => {
setError(null);
setIsCopied(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const copy = useCallback(
async (text) => {
if (!text) {
const error = new Error('Cannot copy empty text');
setError(error);
onError?.(error);
return;
}
setIsLoading(true);
setError(null);
try {
let success = false;
if (isSupported) {
try {
await navigator.clipboard.writeText(text);
success = true;
} catch (clipboardError) {
if (useFallback) {
success = fallbackCopy(text);
} else {
throw clipboardError;
}
}
} else if (useFallback) {
success = fallbackCopy(text);
} else {
throw new Error('Clipboard API is not supported');
}
if (success) {
setIsCopied(true);
onSuccess?.();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
}, timeout);
} else {
throw new Error('Failed to copy text to clipboard');
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to copy');
setError(error);
setIsCopied(false);
onError?.(error);
} finally {
setIsLoading(false);
}
},
[isSupported, useFallback, timeout, onSuccess, onError]
);
return { copy, isLoading, error, isCopied, reset, isSupported };
}
The fallback mechanism creates a temporary textarea element, selects its content, and uses the older document.execCommand('copy') method. While less elegant, this approach ensures compatibility with older browsers.
Advanced Features
For production applications at companies like Alibaba or ByteDance, you might need additional features like copying rich content, handling large text, or tracking copy events for analytics.
Advanced TypeScript Version
import { useState, useCallback, useRef } from 'react';
interface CopyItem {
text?: string;
html?: string;
blob?: Blob;
}
interface UseClipboardOptions {
timeout?: number;
useFallback?: boolean;
onSuccess?: () => void;
onError?: (error: Error) => void;
maxRetries?: number;
}
interface UseClipboardReturn {
copy: (text: string | CopyItem) => Promise<void>;
isLoading: boolean;
error: Error | null;
isCopied: boolean;
reset: () => void;
isSupported: boolean;
copyCount: number;
}
export function useClipboard(options: UseClipboardOptions = {}): UseClipboardReturn {
const {
timeout = 2000,
useFallback = true,
onSuccess,
onError,
maxRetries = 3,
} = options;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isCopied, setIsCopied] = useState(false);
const [copyCount, setCopyCount] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout>();
const isSupported = Boolean(
typeof window !== 'undefined' && navigator?.clipboard
);
const fallbackCopy = (text: string): boolean => {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
return success;
} catch {
return false;
}
};
const reset = useCallback(() => {
setError(null);
setIsCopied(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const copy = useCallback(
async (item: string | CopyItem, retryCount = 0) => {
const text = typeof item === 'string' ? item : item.text;
if (!text) {
const error = new Error('Cannot copy empty text');
setError(error);
onError?.(error);
return;
}
setIsLoading(true);
setError(null);
try {
let success = false;
if (isSupported && navigator.clipboard.write) {
try {
// Try modern Clipboard API with potential rich content support
await navigator.clipboard.writeText(text);
success = true;
} catch (clipboardError) {
if (retryCount < maxRetries && useFallback) {
// Retry with exponential backoff
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, retryCount) * 100)
);
return copy(item, retryCount + 1);
} else if (useFallback) {
success = fallbackCopy(text);
} else {
throw clipboardError;
}
}
} else if (useFallback) {
success = fallbackCopy(text);
} else {
throw new Error('Clipboard API is not supported');
}
if (success) {
setIsCopied(true);
setCopyCount((prev) => prev + 1);
onSuccess?.();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
}, timeout);
} else {
throw new Error('Failed to copy text');
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to copy');
setError(error);
setIsCopied(false);
onError?.(error);
} finally {
setIsLoading(false);
}
},
[isSupported, useFallback, maxRetries, timeout, onSuccess, onError]
);
return {
copy: (item: string | CopyItem) => copy(item),
isLoading,
error,
isCopied,
reset,
isSupported,
copyCount,
};
}
This advanced version includes retry logic with exponential backoff, a copy count for analytics, and support for more complex copy items.
Real-World Implementation
Here's a practical example: a code snippet component that's common in documentation and blogging platforms. This demonstrates how to use the useClipboard hook in a real-world scenario with proper user feedback.
TypeScript Version
import { useClipboard } from './useClipboard';
interface CodeBlockProps {
code: string;
language?: string;
}
export function CodeBlock({ code, language = 'javascript' } : CodeBlockProps) {
const { copy, isCopied, error, isLoading } = useClipboard({
timeout: 1500,
onSuccess: () => {
console.log('Code copied successfully');
},
onError: (error) => {
console.error('Copy failed:', error.message);
},
});
const handleCopy = async () => {
await copy(code);
};
return (
<div className="code-block">
<div className="code-block__header">
<span className="code-block__language">{language}</span>
<button
onClick={handleCopy}
disabled={isLoading}
className={`code-block__copy-button ${isCopied ? 'copied' : ''}`}
aria-label="Copy code to clipboard"
>
{isLoading ? (
<span>Copying...</span>
) : isCopied ? (
<span>✓ Copied!</span>
) : (
<span>Copy</span>
)}
</button>
</div>
{error && (
<div className="code-block__error" role="alert">
Failed to copy: {error.message}
</div>
)}
<pre className="code-block__pre">
<code className={`language-${language}`}>{code}</code>
</pre>
</div>
);
}
JavaScript Version
import { useClipboard } from './useClipboard';
export function CodeBlock({ code, language = 'javascript' }) {
const { copy, isCopied, error, isLoading } = useClipboard({
timeout: 1500,
onSuccess: () => {
console.log('Code copied successfully');
},
onError: (error) => {
console.error('Copy failed:', error.message);
},
});
const handleCopy = async () => {
await copy(code);
};
return (
<div className="code-block">
<div className="code-block__header">
<span className="code-block__language">{language}</span>
<button
onClick={handleCopy}
disabled={isLoading}
className={`code-block__copy-button ${isCopied ? 'copied' : ''}`}
aria-label="Copy code to clipboard"
>
{isLoading ? (
<span>Copying...</span>
) : isCopied ? (
<span>✓ Copied!</span>
) : (
<span>Copy</span>
)}
</button>
</div>
{error && (
<div className="code-block__error" role="alert">
Failed to copy: {error.message}
</div>
)}
<pre className="code-block__pre">
<code className={`language-${language}`}>{code}</code>
</pre>
</div>
);
}
This practical example showcases several key patterns: handling loading states, displaying success feedback, showing error messages, and managing the copy timeout. The button is disabled while copying to prevent multiple concurrent requests, and the visual feedback changes from "Copy" to "Copying..." to "✓ Copied!" to provide clear user guidance.
Consider a more complex scenario on a documentation platform. You might want to track which code snippets users copy for analytics—this helps identify the most useful examples. You can integrate the copyCount return value with your analytics service.
Advanced Real-World Usage
import { useClipboard } from './useClipboard';
import { useEffect } from 'react';
interface CodeBlockProps {
code: string;
language?: string;
snippetId?: string;
}
export function CodeBlock({ code, language = 'javascript', snippetId }: CodeBlockProps) {
const { copy, isCopied, error, isLoading, copyCount } = useClipboard({
timeout: 1500,
onSuccess: () => {
// Track analytics
if (snippetId) {
trackEvent('snippet_copied', { snippetId, language });
}
},
});
return (
<div className="code-block">
{/* header and button code... */}
<pre className="code-block__pre">
<code className={`language-${language}`}>{code}</code>
</pre>
</div>
);
}
FAQ
Q: Why use a custom hook instead of calling the Clipboard API directly?
A: A custom hook abstracts away the complexity of handling state, errors, timeouts, and browser compatibility. It promotes code reuse across your application and makes testing easier. When you update the hook's logic in one place, all components using it benefit automatically.
Q: Does the hook work with HTTPS-only requirements?
A: Yes, the modern Clipboard API requires HTTPS for security reasons (except on localhost). The hook respects this and will throw an appropriate error if called in an insecure context. The fallback mechanism using document.execCommand() works on both HTTP and HTTPS, which is why it's useful for legacy support.
Q: Can I copy HTML or other rich content?
A: The basic hook copies plain text only. The advanced version includes structure for supporting rich content items with HTML properties, though full browser implementation varies. For complex scenarios, consider using the navigator.clipboard.write() API with ClipboardItem objects, which can handle multiple data types.
Q: How do I prevent the "Copying..." feedback from displaying too long?
A: The timeout option controls how long the success feedback is shown. Adjust it based on your UI. Most users find 1500-2000ms optimal for quick feedback without being too distracting. You can also use the reset() method to clear feedback programmatically.
Q: What if multiple copy operations happen simultaneously?
A: The hook manages this through the isLoading state. By disabling the copy button when isLoading is true, you prevent race conditions. The final timeout always clears based on the most recent copy operation, which is the expected behavior for most use cases.
Q: Does the hook work in SSR/Next.js environments?
A: Yes, the hook includes a check for typeof window !== 'undefined' before accessing browser APIs. In SSR contexts, isSupported will be false, and you should disable the copy functionality on the server and enable it only on the client.
Related Articles:
- Custom Hooks Patterns in React
- useEffect: Deep Dive Into React's Effect Hook
- useCallback: Optimizing Function References
- Error Handling Best Practices in React
- Building Accessible React Components
Questions? Share your clipboard implementation challenges in the comments below! Did you encounter browser compatibility issues or need specific clipboard features? Let's discuss your use cases and improvements to the hook.
Google AdSense Placeholder
CONTENT SLOT