Copy-to-clipboard in React with TypeScript

René Kulik on 02.01.2023

Introduction

In this blogpost I am going to demonstrate how to create a copy-to-clipboard functionality using React and TypeScript.

For this purpose I implement a simple application. This application displays one button. When clicked, the current number of milliseconds elapsed since the epoch gets copied to the clipboard.

Planning

Usually a normal function would be sufficient for this kind of functionality. But I also want to display a notification to the user, stating that the copy process was successful or failed. Therefore, I decided to create a reusable useCopyToClipboard hook.

Here is a list of requirements the hook must cover:

Based on these requirements the hook has the following API:

const { copyToClipboard, copiedText, error } = copyToClipboard(2000);

The core functionality is exposed by copyToClipboard. Whenever copiedText is defined, the copy process was successful. When error is defined, something went wrong. 2000 milliseconds after copying, everything will be resetted.

Implementation

TL;DR: Final implementation

In the following section I am going to implement the hook step by step:

Copy-to-clipboard browser APIs

There are 2 browser APIs available to copy a text to the clipboard, the deprecated document.execCommand('copy', ...) and the new navigator.clipboard.writeText(...). To support a wide range of browsers, both will be used. Unfortunately, those APIs work differently. document.execCommand('copy', ...) returns a boolean indicating if copying was successful or not. navigator.clipboard.writeText(...) is an async function returning Promise<void>. Create a wrapper function to unify both:

const writeClipboardContent = async (text: string) => {
  if (!navigator.clipboard) {
    return document.execCommand('copy', false, text);
  }

  try {
    await navigator.clipboard.writeText(text);

    return true;
  } catch {
    return false;
  }
}

If navigation.clipboard is not available, document.execCommand('copy', ...) is used as a fallback. In both cases Promise<boolean> is returned.

Core copy-to-clipboard functionality

Now let’s create the first hook draft. This version contains the basic copy functionality and returns the copied text:

import { useState } from "react";

const writeClipboardContent = async (text: string) => {
  if (!navigator.clipboard) {
    return document.execCommand('copy', false, text);
  }

  try {
    await navigator.clipboard.writeText(text);

    return true;
  } catch {
    return false;
  }
}

const useCopyToClipboard = () => {
  const [copiedText, setCopiedText] = useState<string>();

  const copyToClipboard = async (text: string) => {
    if (await writeClipboardContent(text)) {
      setCopiedText(text);
    }
  };

  return {
    copyToClipboard,
    copiedText,
  };
};

Handle errors

For handling errors, introduce an error state. This will be set and returned on failure:

import { useState } from "react";

const writeClipboardContent = async (text: string) => {
  if (!navigator.clipboard) {
    return document.execCommand('copy', false, text);
  }

  try {
    await navigator.clipboard.writeText(text);

    return true;
  } catch {
    return false;
  }
}

const useCopyToClipboard = () => {
  const [copiedText, setCopiedText] = useState<string>();
  const [error, setError] = useState<string>();

  const copyToClipboard = async (text: string) => {
    if (await writeClipboardContent(text)) {
      setCopiedText(text);
      setError(undefined);
    } else {
      setCopiedText(undefined);
      setError('Copy to clipboard failed');
    }
  };

  return {
    copyToClipboard,
    copiedText,
    error,
  };
};

Reset the hook

When using the hook, the corresponding state will be set but not unset afterwards. Add a parameter that defines the reset time (default value: 2000 milliseconds). Furthermore use useRef to create a reset timeout. This will be set whenever something gets copied to the clipboard, resetTimeout.current = setTimeout(handleReset, resetTime). When you click the button again, before the reset timeout ran out, the timeout will be restarted. We also extract the state handling into dedicated functions:

import { useRef, useState } from "react";

const RESET_TIME_IN_MILLISECONDS = 2000;

const writeClipboardContent = async (text: string) => {
  if (!navigator.clipboard) {
    return document.execCommand('copy', false, text);
  }

  try {
    await navigator.clipboard.writeText(text);

    return true;
  } catch {
    return false;
  }
}

const useCopyToClipboard = (resetTime = RESET_TIME_IN_MILLISECONDS) => {
  const [copiedText, setCopiedText] = useState<string>();
  const [error, setError] = useState<string>();
  const resetTimeout = useRef<NodeJS.Timeout>();

  const handleSuccesfulCopy = (copiedText: string) => {
    setCopiedText(copiedText);
    setError(undefined);
  };

  const handleFailedCopy = (error: string) => {
    setCopiedText(undefined);
    setError(error);
  };

  const handleReset = () => {
    setCopiedText(undefined);
    setError(undefined);
  };

  const copyToClipboard = async (text: string) => {
    if (resetTimeout.current) {
      clearTimeout(resetTimeout.current);
    }

    (await writeClipboardContent(text))
      ? handleSuccesfulCopy(text)
      : handleFailedCopy("Copy to clipboard failed");
 
    resetTimeout.current = setTimeout(handleReset, resetTime);
  };

  return {
    copyToClipboard,
    copiedText,
    error,
  };
};

Final implementation

With the reset all hook requirements are fulfilled. As a final step use useCopyToClipboard as part of the example application described in the introduction:

import { useRef, useState } from "react";

const RESET_TIME_IN_MILLISECONDS = 2000;

const writeClipboardContent = async (text: string) => {
  if (!navigator.clipboard) {
    return document.execCommand("copy", false, text);
  }

  try {
    await navigator.clipboard.writeText(text);

    return true;
  } catch {
    return false;
  }
};

const useCopyToClipboard = (resetTime = RESET_TIME_IN_MILLISECONDS) => {
  const [copiedText, setCopiedText] = useState<string>();
  const [error, setError] = useState<string>();
  const resetTimeout = useRef<NodeJS.Timeout>();

  const handleSuccesfulCopy = (copiedText: string) => {
    setCopiedText(copiedText);
    setError(undefined);
  };

  const handleFailedCopy = (error: string) => {
    setCopiedText(undefined);
    setError(error);
  };

  const handleReset = () => {
    setCopiedText(undefined);
    setError(undefined);
  };

  const copyToClipboard = async (text: string) => {
    if (resetTimeout.current) {
      clearTimeout(resetTimeout.current);
    }

    (await writeClipboardContent(text))
      ? handleSuccesfulCopy(text)
      : handleFailedCopy("Copy to clipboard failed");

    resetTimeout.current = setTimeout(handleReset, resetTime);
  };

  return {
    copyToClipboard,
    copiedText,
    error,
  };
};

const App = () => {
  const { copyToClipboard, copiedText, error } = useCopyToClipboard();

  return (
    <div>
      <button onClick={() => copyToClipboard(Date.now().toString())}>Copy to clipboard</button>
      {copiedText && <p style={{ color: "green" }}>Copy to clipboard succeeded</p>}
      {error && <p style={{ color: "red" }}>{error}</p>}
    </div>
  );
}

export default App;

As you can see, the App component displays a button which triggers the copy process. It also shows a success- or error-message based on what the hook returns.

Conclusion

That’s it. In this blogpost I described how to implement a copy-to-clipboard functionality using React and TypeScript. For this purpose I created a hook that is also capable of handling errors and resetting itself.