Sharing, Clipboard and the Browser

Open the native share sheet, read and write the clipboard, and choose between expo-web-browser and an embedded WebView.

Syntaxconst result = await Share.share({ message: "text and link" }, { dialogTitle: "Share" })

Three small jobs that users expect from every app: send this somewhere, copy this, open this link.

The share sheet

Share comes from react-native itself, no install needed. Share.share(content, options) takes { message } or { url } (at least one is required, and title is iOS only) and resolves with an object whose action is Share.sharedAction or Share.dismissedAction. Android always resolves with sharedAction, so you cannot tell there whether anything was actually sent. Android also carries a single text payload, so put the link inside message rather than relying on url. dialogTitle is Android only; subject and excludedActivityTypes are iOS only.

The clipboard

expo-clipboard gives setStringAsync(text), getStringAsync() and hasStringAsync(). Writing is always allowed. Reading is privacy-sensitive: iOS shows a paste prompt, and a refusal resolves with an empty string instead of throwing.

Links

WebBrowser.openBrowserAsync(url) opens a system browser sheet over your app, SFSafariViewController on iOS and Chrome Custom Tabs on Android. The user gets the real address bar, the padlock and their saved passwords; you get a promise that resolves when the sheet closes, with type of cancel or dismiss on iOS and opened on Android. An embedded WebView from react-native-webview is a view inside your layout, which means you must build the chrome yourself and you own the content. Use the browser for anything you do not control, and especially for sign-in.

Example

Example · tsx
import { useState } from "react";
import * as Clipboard from "expo-clipboard";
import { Pressable, Share, StyleSheet, Text, View } from "react-native";

type ShareRowProps = { title: string; url: string };

export default function ShareRow({ title, url }: ShareRowProps) {
  const [copied, setCopied] = useState(false);

  async function openSheet() {
    const message = `${title} ${url}`;
    // { title, message } is shorthand for { title: title, message: message }.
    const result = await Share.share(
      { title, message }, // Android carries one text payload, so the link goes in message
      { dialogTitle: "Share article" }, // Android only
    );
    // Android always reports sharedAction; only iOS reports a dismissal.
    if (result.action === Share.sharedAction) setCopied(false);
  }

  async function copyLink() {
    await Clipboard.setStringAsync(url);
    setCopied(true);
  }

  return (
    <View style={styles.row}>
      <Pressable onPress={openSheet} style={styles.button}>
        <Text style={styles.label}>Share</Text>
      </Pressable>
      <Pressable onPress={copyLink} style={styles.button}>
        <Text style={styles.label}>{copied ? "Link copied" : "Copy link"}</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  // flexDirection defaults to column, so a row must say so.
  row: { flexDirection: "row", gap: 12 },
  button: { paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, backgroundColor: "#e5e7eb" },
  label: { fontSize: 16, color: "#111827" },
});

When to use it

  • Add a Share button to an article or a product page.
  • Give the user a Copy link button with a visible confirmation.
  • Open terms, help or a support article without leaving the app.
  • Let a user paste a referral or coupon code instead of typing it.

More examples

Opening a link in the system browser

This is the default for any external link: terms, a help article, a blog post. The sheet keeps the user inside your app's task, so there is no cold app switch and no lost navigation state, and it closes back to exactly this screen. Note the result types differ by platform, so compare against both cancel and dismiss rather than assuming one.

Example · tsx
import { useState } from "react";
import * as WebBrowser from "expo-web-browser";
import { Pressable, StyleSheet, Text, View } from "react-native";

type TermsLinkProps = { url: string };

export default function TermsLink({ url }: TermsLinkProps) {
  const [returned, setReturned] = useState(false);

  async function open() {
    const result = await WebBrowser.openBrowserAsync(url, {
      // iOS only: a card over the app instead of a full-screen takeover.
      presentationStyle: WebBrowser.WebBrowserPresentationStyle.PAGE_SHEET,
    });
    // iOS resolves with cancel or dismiss; Android resolves with opened.
    if (result.type === "cancel" || result.type === "dismiss") {
      setReturned(true);
    }
  }

  return (
    <View style={styles.wrap}>
      <Pressable onPress={open}>
        <Text style={styles.link}>Read the terms</Text>
      </Pressable>
      {returned ? <Text style={styles.hint}>Welcome back</Text> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  wrap: { gap: 8, padding: 16 },
  // textDecorationLine is the React Native spelling; there is no text-decoration here.
  link: { fontSize: 16, color: "#1d4ed8", textDecorationLine: "underline" },
  hint: { fontSize: 14, color: "#6b7280" },
});

Pasting a code into a field

Users copy codes from email and then fight the keyboard. A Paste button removes that, and the guard chain matters: hasStringAsync avoids a pointless prompt when the clipboard is empty, and the empty-string check means a denied paste leaves the field alone instead of clearing it. On screen: an outlined field and a Paste button; tapping Paste fills the field with the clipboard text in capitals.

Example · tsx
import { useState } from "react";
import * as Clipboard from "expo-clipboard";
import { Button, StyleSheet, TextInput, View } from "react-native";

export default function CouponField() {
  const [code, setCode] = useState("");

  async function pasteCode() {
    // hasStringAsync reports that text exists without reading it.
    if (!(await Clipboard.hasStringAsync())) return;
    const text = await Clipboard.getStringAsync();
    // A refused paste on iOS 16+ resolves with "" rather than throwing.
    if (text.trim() === "") return;
    setCode(text.trim().toUpperCase());
  }

  return (
    <View style={styles.wrap}>
      <TextInput
        value={code}
        onChangeText={setCode}
        placeholder="Coupon code"
        autoCapitalize="characters"
        autoCorrect={false}
        style={styles.input}
      />
      <Button title="Paste" onPress={pasteCode} />
    </View>
  );
}

const styles = StyleSheet.create({
  wrap: { gap: 12, padding: 16 },
  input: { borderWidth: 1, borderColor: "#d1d5db", borderRadius: 8, padding: 12, fontSize: 16 },
});

Browser or WebView

Content you own can live in an embedded WebView, because you control the page and can style it to match the app. Everything else belongs in the system browser: the user sees the real domain, cookies and passwords stay in their browser, and an identity provider will often refuse to sign in inside a WebView at all. For sign-in specifically use openAuthSessionAsync rather than openBrowserAsync, since it waits for your redirect URL and resolves with it.

Example · tsx
export type LinkKind = "browser" | "webview";

// One decision, written once, instead of a judgement call per link.
export function chooseLinkTarget(url: string, ownDomains: string[]): LinkKind {
  const host = new URL(url).hostname.toLowerCase();
  const isOurs = ownDomains.some(
    (domain) => host === domain || host.endsWith(`.${domain}`),
  );
  return isOurs ? "webview" : "browser";
}

const ourDomains = ["example.com"];

console.log(chooseLinkTarget("https://help.example.com/faq", ourDomains));
// Output: webview
console.log(chooseLinkTarget("https://news.site/article", ourDomains));
// Output: browser

Discussion

  • Be the first to comment on this lesson.