Manage your customers’ clipboards with React hooks.

  • Clipboard APIs are both synchronous and asynchronous, and we need to account for not knowing whether the action will occur immediately or with delay.
  • Clipboard APIs, being a security concern, are permission-based in modern browsers. The primary reason they are asynchronous is due to the time between you attempt to hijack the user’s clipboard and the customer actually approving the permission request.
  • Clipboard APIs are not integrated into TypeScript by default. use-clippy is a TypeScript package, so we have the joy of writing those types ourselves.

“I don’t care how it works. I just want it now.” ⏳

You can install use-clippy from NPM:

  • yarn add use-clippy
import useClippy from 'use-clippy';function MyComponent() {
const [ clipboard, setClipboard ] = useClippy();
// ...
}

Creating a Hook 🎣

I always start every project by imagining how I would want to interact with the package as a consumer/developer. As a React hook, I want an interface that is intuitive. As such, use-clippy is patterned after useState, the built-in React hook for managing a value and its setter.

const [clipboard, setClipboard] = useClippy();
type ClipboardTuple = [
string, // getter for the clipboard value
(clipboard: string) => void, // setter for the clipboard value
];
function useClippy(): ClipboardTuple {
const [ clipboard, setClipboard ] = useState('');
return [ clipboard, ... ];
}

The Clipboard API 📋

There are two ways to read from a clipboard. Modern browsers have an asynchronous, permission-based clipboard API. A developer may request access to a user’s clipboard, at which point the browser prompts the user to authorize this behavior. Older browsers have a synchronous clipboard API, wherein the developer simply tells the browser to read or write to the clipboard, and the browser simply does it or refuses, with no user interaction.

// Determine if the asynchronous clipboard API is enabled.
const IS_CLIPBOARD_API_ENABLED: boolean = (
typeof navigator === 'object' &&
typeof (navigator as ClipboardNavigator).clipboard === 'object'
);

Why “as ClipboardNavigator”?

TypeScript does not contain the Clipboard API in its definition of the navigator object, despite it being there in many browsers. We must override TypeScript’s definitions in a few places to essentially say, “We know better.”

// In addition to the navigator object, we also have a clipboard
// property.
interface ClipboardNavigator extends Navigator {
clipboard: Clipboard & ClipboardEventTarget;
}
// The Clipboard API supports readText and writeText methods.
interface Clipboard {
readText(): Promise<string>;
writeText(text: string): Promise<void>;
}
// A ClipboardEventTarget is an EventTarget that additionally
// supports clipboard events (copy, cut, and paste).
interface ClipboardEventTarget extends EventTarget {
addEventListener(
type: 'copy',
eventListener: ClipboardEventListener,
): void;
addEventListener(
type: 'cut',
eventListener: ClipboardEventListener,
): void;
addEventListener(
type: 'paste',
eventListener: ClipboardEventListener,
): void;
removeEventListener(
type: 'copy',
eventListener: ClipboardEventListener,
): void;
removeEventListener(
type: 'cut',
eventListener: ClipboardEventListener,
): void;
removeEventListener(
type: 'paste',
eventListener: ClipboardEventListener
): void;
}
// A ClipboardEventListener is an event listener that accepts a
// ClipboardEvent.
type ClipboardEventListener =
| EventListenerObject
| null
| ((event: ClipboardEvent) => void);

Re-render when the clipboard is updated.

The asynchronous Clipboard API allows us to subscribe to clipboard changes. We can use this to synchronize our React component’s local state value to the user’s actual clipboard value.

// If the user manually updates their clipboard, re-render with the
// new value.
if (IS_CLIPBOARD_API_ENABLED) {
useEffect(() => {
const clipboardListener = ...;
const nav: ClipboardNavigator =
navigator as ClipboardNavigator;
nav.clipboard.addEventListener('copy', clipboardListener);
nav.clipboard.addEventListener('cut', clipboardListener);
return () => {
nav.clipboard.removeEventListener(
'copy',
clipboardListener,
);
nav.clipboard.removeEventListener(
'cut',
clipboardListener,
);
};
},
[ clipboard ]);
}
const clipboardListener = ({ clipboardData }: ClipboardEvent) => {
const cd: DataTransfer | null =
clipboardData ||
(window as ClipboardDataWindow).clipboardData ||
null;
if (cd) {
const text = cd.getData('text/plain');
if (clipboard !== text) {
setClipboard(text);
}
}
};
interface ClipboardDataWindow extends Window {
clipboardData: DataTransfer | null;
}

Initial Clipboard Value 🔰

Above, we allowed our component to update asynchronously as the customer updates their clipboard. However, when the component first mounts, we need to read the clipboard immediately. Here, we may attempt to read the clipboard synchronously.

// Try to read synchronously.
try {
const text = read();
if (clipboard !== text) {
setClipboard(text);
}
}

Reading the clipboard synchronously.

In order to read the clipboard synchronously, we must first paste the clipboard somewhere.

const read = (): string => {  // Create a temporary input solely to paste.
const i = createInput();
i.focus();
// Attempt to synchronously paste.
// (Will return true on success, false on failure.)
const success = document.execCommand('paste');
// If we don't have permission to read the clipboard, cleanup and
// throw an error.
if (!success) {
removeInput(i);
throw NOT_ALLOWED_ERROR;
}
// Grab the value, remove the temporary input, then return the
// value.
const value = i.value;
removeInput(i);
return value;
};

Initializing the local state value asynchronously.

When synchronous initialization fails, we can fallback to the slower, but modern asynchronous Clipboard API. If it is enabled, simply read from it and set the local state.

// If synchronous reading is disabled, try to read asynchronously.
catch (e) {
if (IS_CLIPBOARD_API_ENABLED) {
const nav: ClipboardNavigator = navigator as ClipboardNavigator;
nav.clipboard.readText()
.then(text => {
if (clipboard !== text) {
setClipboard(text);
}
})
// Fail silently if an error occurs.
.catch(() => {});
}
}

Set the Clipboard ✍

At the very beginning, we created a tuple that contains the clipboard’s value for reading the user’s clipboard and a setter for setting the user’s clipboard. We have now implemented the first item in that tuple, and it is now time to create the setter.

function clippySetter(text: string): void {
try {
write(text);
setClipboard(text);
}
catch (e) {
if (IS_CLIPBOARD_API_ENABLED) {
const nav: ClipboardNavigator =
navigator as ClipboardNavigator;
nav.clipboard.writeText(text)
.then(() => {
setClipboard(text);
})
.catch(() => {});
}
}
}
const write = (text: string): void => {
const i = createInput();
i.setAttribute('value', text);
i.select();
const success = document.execCommand('copy');
removeInput(i);
if (!success) {
throw NOT_ALLOWED_ERROR;
}
};

Always update. 🆕

When reading the clipboard, we would only set the React local state if the new value was different than the existing value. When setting the clipboard, we always set the user’s clipboard and the React local state, even if the new value is the same as the existing one.

Conclusion 🔚

This package is available on NPM and open source on GitHub.

Senior front end engineer / charlesstover.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store