Using the Web Share API in React

A guide to adding a Share button to a React app using the Web Share API.

The Web Share API allows a site to share text, links, files, and other content to user-selected share targets, utilizing the sharing mechanisms of the underlying operating system. Source: https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API

You can use the functionality to add a rich share experience to a site which accesses a device's Native Share, if supported.

Key Points

  • Web Share API doesn't have 100% coverage, as of Chrome 104 this only has partial support, so an approach is needed to ensure this is covered.
  • The Web Share API requires a https secure context.

The API has 2 methods navigator.canShare() and navigator.share() these can be used to check sharing support and call the systems share method.

Prerequisites

  • An already setup React app, this can be a Remix, Next.JS, Create React App or any other Framework as approach agnostic.

Step 1 - Adding Share Wrapper

In the React app create a folder called Share and add index.ts and Share.tsx files.

// index.ts

export { default as Share } from "./Share"

Exposing the Share functionality like this is optional but my preferred approach for constructing components.

// Share.tsx

import { type FC, useState } from "react";


const Share: FC<Props> = ({
  children,
  shareData,
  onInteraction,
  onSuccess,
  onError,
  disabled
}) => {
  const [openPopup, setOpenPopup] = useState(false);

  const handleNonNativeShare = () => {
    setOpenPopup(true);
  };

  return (
    <>
        {children}
    </>
  );
};

interface Props {
  children: React.ReactNode;
  shareData: ShareData;
  onSuccess?: () => void;
  onError?: (error?: unknown) => void;
  onInteraction?: () => void;
  disabled?: boolean;
}

export default Share;

The above starts to bootstrap the components required, and map out the component's props which we are exposing for use.

Worth noting in the Props ShareData is a Typescript language type hence not defining here.

The structure for following components is; a button which will call the navigator.share() functionality and enable the custom Share experience if the Web Share API isn't available.

Step 2 - Adding Share Controller

Add a file called ShareController.tsx this will be our button, however we are going to leave the content of the button open to the consumer to define via children prop in the Share.tsx file.

// ShareController.tsx

import { type FC } from "react";

const ShareController: FC<Props> = ({
  children,
  shareData,
  onInteraction,
  onSuccess,
  onError,
  onNonNativeShare,
  disabled,
}) => {
  const handleOnClick = async () => {
    onInteraction && onInteraction();
    if (navigator.share) {
      try {
        await navigator.share(shareData);
        onSuccess && onSuccess();
      } catch (err) {
        onError && onError(err);
      }
    } else {
      onNonNativeShare && onNonNativeShare();
    }
  };
  
  return (
    <button
      onClick={handleOnClick}
      type="button"
      disabled={disabled}
    >
      {children}
    </button>
  );
};

interface Props {
  children: React.ReactNode;
  shareData: ShareData;
  onSuccess?: () => void;
  onError?: (error?: unknown) => void;
  onNonNativeShare?: () => void;
  onInteraction?: () => void;
  disabled?: boolean;
}

export default ShareController;

The core part of this component is the handleOnClick function. It's worth noting this is an async method as the navigator.share method resolves with a Promise.

...
const handleOnClick = async () => {
    onInteraction && onInteraction();
    if (navigator.share) {
      try {
        await navigator.share(shareData);
        onSuccess && onSuccess();
      } catch (err) {
        onError && onError(err);
      }
    } else {
      onNonNativeShare && onNonNativeShare();
    }
  };
  
 ...

Just exploring the above in a bit more detail:

onInteraction - allows a consumer provided function to be called when the share button is clicked.

onSuccess - allows a consumer provided function to be called when successful.

onError - resolve any errors from Web Share API, note a user cancelling the native share dialog will call this method.

onNonNativeShare - handle the cases when a Native share experience isn't available.

I've included a prop to disable the button if required, but some considerations should be taken as to whether this is appropriate for your use case, in particularly regarding accessibility concerns.

Step 3 - Adding Custom Share Popup

Create a new file SharePopup.tsx and add the following:

// SharePopup.tsx

import { type FC, useState } from "react";

const SharePopup: FC<Props> = ({
  shareData,
  onClose,
  onError
}) => {
  const [state, setState] = useState<ShareState>("pending");

  const copyClicked = async () => {
    try {
      await navigator.clipboard.writeText(shareData?.url || "");
      setState("success");
    } catch (err) {
      onError && onError(err);
      setState("error");
    }
  };

  const getButtonText = (state: ShareState) => {
    switch (state) {
      case "success":
        return "Link copied";
      case "pending":
      default:
        return "Copy link";
    }
  };

  return (
    <div>
      <div>
        <div>
          <div>
            <div>
              <div>
                <h3>
                  {shareData.title}
                </h3>
                <button onClick={onClose}>
                  <span>Close Share</span>
                  <div aria-hidden="true">
                    <svg
                      xmlns="http://www.w3.org/2000/svg"
                      width="24"
                      height="24"
                      viewBox="0 0 24 24"
                    >
                      <g id="close">
                        <path
                          id="x"
                          d="M18.717 6.697l-1.414-1.414-5.303 5.303-5.303-5.303-1.414 1.414 5.303 5.303-5.303 5.303 1.414 1.414 5.303-5.303 5.303 5.303 1.414-1.414-5.303-5.303z"
                        />
                      </g>
                    </svg>
                  </div>
                </button>
              </div>
              <div>
                {state === "error" ? (
                  <div>
                      <p>
                        Unable to copy to clipboard, please manually copy the
                        url to share.
                      </p>
                  </div>
                ) : null}
                <input
                  value={shareData.url}
                  readOnly
                />
                <button
                  onClick={copyClicked}
                >
                  {getButtonText(state)}
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

type ShareState = "pending" | "success" | "error";

interface Props {
  shareData: ShareData;
  onClose: () => void;
  onError?: (error?: unknown) => void;
}

export default SharePopup;

I've removed all styling from above and how you choose to implement the above is dependent upon your approach.

The core function is the copyClicked:

...
const copyClicked = async () => {
    try {
      await navigator.clipboard.writeText(shareData?.url || "");
      setState("success");
    } catch (err) {
      onError && onError(err);
      setState("error");
    }
  };
...

Worth noting again this is another async method. This is to handle to case when native support isn't available, to copy the shareData's url prop calling the navigator.clipboard.writeText() method. If this errors, perhaps due to permissions for example a message will appear to indicate a user should manually copy the text.

Step 4 - Combining the Components

Going back to Share.tsx if we now update to include our newly added components.

// Share.tsx

import { type FC, useState } from "react";

import ShareController from "./ShareController";
import SharePopup from "./SharePopup";

const Share: FC<WithColors<Props>> = ({
  children,
  shareData,
  onInteraction,
  onSuccess,
  onError,
  disabled
}) => {
  const [openPopup, setOpenPopup] = useState(false);

  const handleNonNativeShare = () => {
    setOpenPopup(true);
  };

  return (
    <>
      <ShareController
        shareData={shareData}
        onInteraction={onInteraction}
        onSuccess={onSuccess}
        onError={onError}
        onNonNativeShare={handleNonNativeShare}
        disabled={disabled}
      >
        {children}
      </ShareController>
      {openPopup ? (
        <SharePopup
          shareData={shareData}
          onClose={() => setOpenPopup(false)}
        />
      ) : null}
    </>
  );
};

interface Props {
  children: React.ReactNode;
  shareData: ShareData;
  onSuccess?: () => void;
  onError?: (error?: unknown) => void;
  onInteraction?: () => void;
  disabled?: boolean;
}

export default Share;

We forward a number of functions to the ShareController.tsx so now we can use the Share component by providing the appropriate ShareData prop.

Our consumer may look something similar to below:

// ShareConsumer.tsx
...
import Share from "./Share";
...
const shareData = {
  title: "Share",
  text: "Share message",
  url: "https://www.brannen.dev"
}
...
    <Share shareData={shareData}>
        <span>Share</span>
    </Share>
...

Conclusion

The Share.tsx provides a wrapper for the ShareController.tsx and SharePopup.tsx in order to access the Web Share API. This enables the Share Component to be imported into consumers and provided with shareData which triggers the end users Native Share experience or a custom Share Popup to copy the provided url.

On my phone:

Image of native share on android phone

and on Chrome browser:

Image of custom share popup

Further considerations