useCopyToClipboard — Copia texto al portapapeles con retroalimentación de estado
Receta
import { useState, useCallback, useRef } from "react";
interface UseCopyToClipboardReturn {
/** El texto copiado más recientemente, o null */
copiedText: string | null;
/** Si el texto fue copiado recientemente (se reinicia después del timeout) */
isCopied: boolean;
/** Copia el texto dado al portapapeles */
copy: (text: string) => Promise<boolean>;
/** Reinicia el estado copiado manualmente */
reset: () => void;
}
function useCopyToClipboard(
resetDelay: number = 2000
): UseCopyToClipboardReturn {
const [copiedText, setCopiedText] = useState<string | null>(null);
const [isCopied, setIsCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reset = useCallback(() => {
setCopiedText(null);
setIsCopied(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const copy = useCallback(
async (text: string): Promise<boolean> => {
// Intenta primero la API Clipboard moderna
if (navigator?.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
setIsCopied(true);
// Reinicio automático después del retraso
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setIsCopied(false);
timerRef.current = null;
}, resetDelay);
return true;
} catch {
// Falló la API Clipboard (p. ej., permisos denegados)
}
}
// Alternativa: execCommand para navegadores más antiguos
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const success = document.execCommand("copy");
document.body.removeChild(textarea);
if (success) {
setCopiedText(text);
setIsCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setIsCopied(false);
timerRef.current = null;
}, resetDelay);
}
return success;
} catch {
return false;
}
},
[resetDelay]
);
return { copiedText, isCopied, copy, reset };
}Cuándo usarlo: Tienes un botón "Copiar" junto a fragmentos de código, claves API, URLs o enlaces de compartir y deseas retroalimentación visual cuando la copia tiene éxito.
Ejemplo Funcionando
"use client";
function CodeBlock({ code }: { code: string }) {
const { isCopied, copy } = useCopyToClipboard(3000);
return (
<div style={{ position: "relative", background: "#1e1e1e", padding: 16, borderRadius: 8 }}>
<pre style={{ color: "#d4d4d4", margin: 0 }}>
<code>{code}</code>
</pre>
<button
onClick={() => copy(code)}
style={{
position: "absolute",
top: 8,
right: 8,
padding: "4px 12px",
background: isCopied ? "#22c55e" : "#3b82f6",
color: "#fff",
border: "none",
borderRadius: 4,
cursor: "pointer",
transition: "background 0.2s",
}}
>
{isCopied ? "¡Copiado!" : "Copiar"}
</button>
</div>
);
}
function ShareLink({ url }: { url: string }) {
const { isCopied, copy } = useCopyToClipboard();
return (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input value={url} readOnly style={{ flex: 1, padding: 8 }} />
<button onClick={() => copy(url)}>
{isCopied ? "¡Enlace copiado!" : "Compartir"}
</button>
</div>
);
}Qué demuestra esto:
- El texto y color del botón cambian para confirmar que la copia fue exitosa
isCopiedse reinicia automáticamente después de 3 segundos (o 2 segundos por defecto)- La función
copyasincrónica devuelve un boolean para que los llamadores verifiquen el éxito - Alternativa a
document.execCommandpara navegadores sin la API Clipboard
Análisis Profundo
Cómo Funciona
- API Clipboard:
navigator.clipboard.writeTextes la API moderna basada en promesas. Requiere un contexto seguro (HTTPS) y puede solicitar permiso. - Alternativa execCommand: Para navegadores más antiguos, el hook crea un
textareafuera de pantalla, selecciona su contenido y ejecuta el comandocopy. Esto está obsoleto pero ampliamente soportado. - Temporizador de reinicio automático: Después de una copia exitosa,
isCopiedcambia atruey se reinicia automáticamente después deresetDelayms. Esto proporciona retroalimentación "¡Copiado!" sin limpieza manual. - Limpieza del temporizador: Los temporizadores anteriores se borra antes de establecer otros nuevos, evitando un estado obsoleto de copias superpuestas.
Parámetros y Valores de Retorno
| Parámetro | Tipo | Por defecto | Descripción |
|---|---|---|---|
resetDelay | number | 2000 | Milisegundos antes de que isCopied se reinicie a false |
| Retorno | Tipo | Descripción |
|---|---|---|
copiedText | string o null | El último texto copiado exitosamente |
isCopied | boolean | Si una copia reciente fue exitosa (se reinicia automáticamente) |
copy | (text: string) => Promise<boolean> | Desencadena copia, devuelve éxito |
reset | () => void | Reinicia manualmente el estado |
Variaciones
Copiar texto enriquecido (HTML): Usa navigator.clipboard.write con un ClipboardItem para contenido formateado:
const blob = new Blob([htmlString], { type: "text/html" });
const item = new ClipboardItem({ "text/html": blob });
await navigator.clipboard.write([item]);Copiar desde un elemento: Acepta una ref en lugar de una cadena y lee innerText:
const copyFromRef = async (ref: React.RefObject<HTMLElement>) => {
const text = ref.current?.innerText ?? "";
return copy(text);
};Notas de TypeScript
- El tipo de retorno es una interfaz nombrada para documentación clara.
copydevuelvePromise<boolean>para que los llamadores puedanawaity reaccionar al fracaso.- No se necesitan genéricos ya que la entrada y salida siempre son cadenas.
Trampas
- Contexto seguro requerido — La API Clipboard solo funciona sobre HTTPS o en localhost. Solución: La alternativa maneja contextos HTTP, pero prueba ambas rutas.
- Activación del usuario requerida — Los navegadores requieren que la copia ocurra en respuesta a un gesto del usuario (clic, pulsación de tecla). Solución: Siempre llama a
copydesde un manejador de eventos, no desde un temporizador o efecto. - Popup de permisos — Algunos navegadores muestran un aviso de permisos para acceso al portapapeles. Solución: Esto está controlado por el navegador; el hook maneja el rechazo con elegancia devolviendo
false. - Deprecación de execCommand —
document.execCommand("copy")está obsoleto y puede ser eliminado. Solución: La API Clipboard es la ruta principal; la alternativa es una red de seguridad para navegadores heredados. - Texto grande — Copiar cadenas muy grandes (múltiples MB) puede bloquear el navegador. Solución: Considera truncar o advertir al usuario para contenido inusualmente grande.
Alternativas
| Paquete | Nombre del Hook | Notas |
|---|---|---|
usehooks-ts | useCopyToClipboard | API similar, sin alternativa |
@uidotdev/usehooks | useCopyToClipboard | Mínimo, solo API Clipboard |
react-use | useCopyToClipboard | Incluye estado de error |
copy-to-clipboard (npm) | copy() | No es un hook; función de utilidad con alternativa |
Preguntas Frecuentes
¿Por qué el hook intenta la API Clipboard primero y recurre a execCommand?
navigator.clipboard.writeTextes la API moderna basada en promesas y es el enfoque preferido.document.execCommand("copy")está obsoleto pero tiene un soporte más amplio en navegadores más antiguos.- El hook intenta la API moderna primero y solo usa la alternativa si falla o no está disponible.
¿Qué hace la bandera isCopied y cuándo se reinicia?
isCopiedcambia atrueinmediatamente después de una copia exitosa.- Se reinicia automáticamente a
falsedespués deresetDelaymilisegundos (por defecto: 2000). - Esto proporciona retroalimentación visual "¡Copiado!" sin limpieza manual.
¿Puedo esperar la función copy para verificar si tuvo éxito?
Sí. copy devuelve Promise<boolean>:
const success = await copy("some text");
if (!success) {
showErrorToast("Copy failed");
}¿Para qué se utiliza la función reset?
reset() borra manualmente copiedText, establece isCopied a false y cancela cualquier temporizador de reinicio automático pendiente. Úsalo cuando necesites reiniciar el estado antes de que se active el reinicio automático (p. ej., al cerrar un modal).
Trampa: Mi llamada a copy falla silenciosamente en una página HTTP. ¿Por qué?
La API Clipboard requiere un contexto seguro (HTTPS o localhost). En HTTP simple, navigator.clipboard es undefined. El hook recurre a execCommand, pero prueba ambas rutas para estar seguro.
Trampa: Llamo a copy() dentro de un useEffect y el navegador la bloquea. ¿Qué está mal?
Los navegadores requieren que el acceso al portapapeles ocurra en respuesta a un gesto del usuario (clic, pulsación de tecla). Llamar a copy() desde un temporizador, efecto o callback asincrónico sin una acción de usuario previa será bloqueado. Siempre desencadénala desde un manejador de eventos.
¿Cómo funciona el enfoque del textarea alternativo?
- Se crea un
<textarea>fuera de pantalla y se añade al DOM. - Su valor se establece al texto objetivo, luego se llama a
select()yexecCommand("copy"). - El textarea se elimina inmediatamente del DOM después de la operación.
¿Cómo copiaría texto enriquecido (HTML) en lugar de texto sin formato?
Usa navigator.clipboard.write con un ClipboardItem:
const blob = new Blob([htmlString], { type: "text/html" });
const item = new ClipboardItem({ "text/html": blob });
await navigator.clipboard.write([item]);¿Por qué el tipo de retorno usa una interfaz nombrada en lugar de un tipo en línea?
La interfaz UseCopyToClipboardReturn nombrada proporciona documentación clara, autocompletado de IDE y puede ser reutilizada si los consumidores necesitan tipificar props que aceptan el valor de retorno del hook.
¿Qué tipo de TypeScript tiene la función copy?
copy se tipifica como (text: string) => Promise<boolean>. La entrada siempre es una cadena y el retorno indica éxito o fracaso. No se necesitan genéricos.
Relacionado
- useToggle — boolean simple para mostrar retroalimentación
- useEventListener — escucha eventos de pegado