Manage your customers’ clipboard with React hooks.

charlesstover

Charles Stover

Posted on October 8, 2019

Manage your customers’ clipboard with React hooks.

Modern web applications have an abundance of tools available for improving user experience. When implementing a new feature, a sizable chunk of UI/UX discussion is typically devoted to reducing the number of necessary clicks and keystrokes required to perform a task. The ability to automate the repetitive or predictable behavior is one of the largest driving forces behind the software industry, and I think it is beautiful that we have blossomed from calculators saving hours of error-prone manual labor to user interfaces automating seconds.
It is no shock that most large projects inevitably reach a point where we can predict that the user is going to want to copy or paste something, and we inevitably attempt to automate that workflow. Clipboard interactions are one of the oldest attempts to hijack a user’s system for their own good, and it is time that these APIs integrate with React.

In this article, I will walk through how I created use-clippy, a React Hook for writing to or reading from the user’s clipboard. Not to be confused with Microsoft Office’s assistant, Clippy 📎.

This package was interesting to develop for a few reasons:

  • Clipboard APIs are old — so old that they have been deprecated and re-invented. We want to make sure that all users, regardless of their browser version, are able to use this feature.
  • 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 with npm install use-clippy or yarn add use-clippy.

Using this package is as easy and intuitive as the useState React Hook.

import useClippy from 'use-clippy';

function MyComponent() {
  const [ clipboard, setClipboard ] = useClippy();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Just use clipboard to read the contents of the user’s clipboard, and use setClipboard to set the contents of the user’s clipboard. It’s that easy.

use-clippy is also open source on GitHub. The source code is available for anyone to peruse, and public contributions are welcome.

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();
Enter fullscreen mode Exit fullscreen mode

With this interface, I have read and write capabilities for the clipboard that match my existing component state management code.

As a TypeScript project, everything will be strongly typed. While there are no parameters to useClippy, there is a return value, which is defined as such:

type ClipboardTuple = [
  string,                      // getter for the clipboard value
  (clipboard: string) => void, // setter for the clipboard value
];
Enter fullscreen mode Exit fullscreen mode

The first thing we’ll need to do is copy the clipboard into a local state for this React component so that changes trigger a re-render.

function useClippy(): ClipboardTuple {
  const [ clipboard, setClipboard ] = useState('');
  return [ clipboard, ... ];
}
Enter fullscreen mode Exit fullscreen mode

While the clipboard value in the state should match the user’s clipboard value (with a browser-enforced delay as the user authorizes permission to do this), the setClipboard function here only sets the local React state value, but not the user’s actual clipboard value. Therefore, that is not the function we will be returning to the component consuming useClippy.

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.

useClippy accounts for both.

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

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);
Enter fullscreen mode Exit fullscreen mode

Now that we know if the asynchronous Clipboard API is enabled, we can use it with graceful degradation.

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 ]);
}
Enter fullscreen mode Exit fullscreen mode

Since IS_CLIPBOARD_API_ENABLED is true, we know that navigator is a ClipboardNavigator as defined above, so we override TypeScript’s definition. When the user updates their clipboard by copying or cutting, we want this component to re-render with the new value, because this component is reading the user’s clipboard. When the component unmounts, we remove these event listeners. The current clipboard value is a dependency, because we use it in the clipboard listener to only re-render the component if the new value is different than the old value.

The clipboard event listener is defined below:

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);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Get the clipboardData from the event itself. In some cases, it is instead defined on the window object. If the user did not allow us to read this value, we may instead receive null.

Again, TypeScript does not support the Clipboard API in its definitions, so we must manually define window as an object that may contain a clipboardData property.

interface ClipboardDataWindow extends Window {
  clipboardData: DataTransfer | null;
}
Enter fullscreen mode Exit fullscreen mode

If the user did authorize us to read the clipboard, we use getData to convert our DataTransfer object to plain text. Only if the new clipboard contents differ from the ones we already have, we set our React component’s local state value to the new clipboard value.

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Reading a clipboard synchronously through older browser APIs is a complex process, so it has been abstracted away and defined below. If it occurs successfully, however, we can set the React local state value to the clipboard value.

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;
};
Enter fullscreen mode Exit fullscreen mode

Creating and removing the temporary input is more of a CSS tutorial — a challenge in the art of hiding the input from user perception while still being accessible to the browser API. An input that has a display value of none or a height or width of 0 cannot be interacted with. If you are interested, you may inspect the source code.

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(() => {});
  }
}
Enter fullscreen mode Exit fullscreen mode

If both synchronous and asynchronous attempts to read the clipboard failed, there is simply nothing we can do. The browser does not support it, and we fail silently.

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(() => {});
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The first thing we do is attempt to write to the clipboard synchronously. We do this with the same complex behavioral pattern we used for reading synchronously.

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

Create an input, give it the value we want in the customer’s clipboard, select the contents of that input, then execute a copy command. It will either succeed to synchronously set the user’s clipboard, or it will throw an error.

In the event it was successful, we set the React component local state to the new value, keeping it in sync with the actual clipboard.

In the event of an error, we fallback to the asynchronous Clipboard API. If it succeeds to writeText, we set the React component local state to the new value. If it does not succeed, we fail silently.

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.

We always set the user’s clipboard in order to account for when the user updates their clipboard from outside the application. In this case, the clipboard value in the local state may be different from the actual clipboard value, and we want to ensure that our new value gets set, even if our local state value is wrong.

We always set the React local state in order to trigger any re-render animations or effects, such as a “Clipboard copied!” notification.

Conclusion 🔚

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

If you have any questions or great commentary, please leave them in the comments below.

To read more of my columns, you may follow me on LinkedIn and Twitter, or check out my portfolio on CharlesStover.com.

💖 💪 🙅 🚩
charlesstover
Charles Stover

Posted on October 8, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related