Integrate the SDK

Ready-made update screens

Copy-paste update screens for React Native: a bottom sheet, a What's new dialog, a required-update screen and a settings card — with previews.

6 sections Edit this page on GitHub

How to use a template

Each screen below is a single file that only uses react, react-native and @fireota/react-native — no extra packages. They take their colours from the FireOTA theme, so one theme restyles all of them, and since they are plain JavaScript they can themselves ship as an OTA update.

  1. 1
    Copy the template file into your app's src/ folder. The prompts also need RequiredUpdateScreen.tsx.
  2. 2
    Replace withFireOTA(App, fireotaConfig) in index.js with FireOTAProvider and pass the template as renderPrompt:
    tsx
    // index.js — wrap the app in FireOTAProvider and pick a template
    import { AppRegistry } from "react-native";
    import { FireOTAProvider } from "@fireota/react-native";
    import App from "./App";
    import { name as appName } from "./app.json";
    import fireotaConfig from "./fireota.config";
    import { UpdateSheet } from "./src/UpdateSheet";       // or UpdateDialog
    
    function Root() {
      return (
        <FireOTAProvider
          config={fireotaConfig}
          theme={{ accent: "#FF5A1F" }}                   // optional: restyles every template
          renderPrompt={(ota) => <UpdateSheet ota={ota} />}
        >
          <App />
        </FireOTAProvider>
      );
    }
    
    AppRegistry.registerComponent(appName, () => Root);
  3. 3
    Build a release build once; after that, publish changes to the screen as normal updates.
Note: The previews on this page use the default theme and a light custom theme. Texts, version and notes come from what you publish: --message becomes the notes and --version the version label.

Bottom sheet with release notes

My App

NEW UPDATE

Version 2.4.1 is ready

1.8 MB · downloaded

Faster checkout

Fixed a crash on login

New dark mode icons

Restart to updateLater

Default theme

My App

NEW UPDATE

Version 2.4.1 is ready

1.8 MB · downloaded

Faster checkout

Fixed a crash on login

New dark mode icons

Restart to updateLater

With a custom theme

UpdateSheet: slides up once an update is downloaded. Restart applies it now; Later hides it until the next cold start.

Shows the version, download size and up to five lines of your release notes (one per line, leading - or • is stripped). Tapping outside the sheet or Android's back button counts as Later.

tsx
// src/UpdateSheet.tsx — a bottom sheet with the release notes once an update is downloaded.
// Use it as renderPrompt: <FireOTAProvider renderPrompt={(ota) => <UpdateSheet ota={ota} />} …>
import React, { useState } from "react";
import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
import type { FireOTAContextValue } from "@fireota/react-native";

import { RequiredUpdateScreen, isRequiredUpdate } from "./RequiredUpdateScreen";

const formatSize = (bytes: number) => (bytes >= 1_048_576 ? `${(bytes / 1_048_576).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`);

export function UpdateSheet({ ota }: { ota: FireOTAContextValue }) {
  // "Later" hides the sheet for this update only; it still applies on the next cold start.
  const [dismissed, setDismissed] = useState<string | null>(null);
  const { theme, available } = ota;

  if (isRequiredUpdate(ota)) return <RequiredUpdateScreen ota={ota} />;

  const ready =
    ota.status === "installed" &&
    ota.pendingReleaseId !== null &&
    ota.pendingReleaseId !== ota.currentReleaseId &&
    dismissed !== ota.pendingReleaseId;
  if (!ready) return null;

  const notes = (available?.notes || available?.message || "Improvements and bug fixes.")
    .split("\n")
    .map((line) => line.replace(/^[-•*]\s*/, "").trim())
    .filter(Boolean);
  const close = () => setDismissed(ota.pendingReleaseId);

  return (
    <Modal visible transparent animationType="slide" statusBarTranslucent onRequestClose={close}>
      <Pressable style={styles.backdrop} onPress={close} accessibilityRole="button">
        {/* Inner Pressable stops taps on the sheet from closing it. */}
        <Pressable style={[styles.sheet, { backgroundColor: theme.card }]} onPress={() => {}}>
          <View style={styles.handle} />
          <Text style={[styles.eyebrow, { color: theme.accent }]}>NEW UPDATE</Text>
          <Text style={[styles.title, { color: theme.text }]}>
            {available?.version ? `Version ${available.version} is ready` : "A new version is ready"}
          </Text>
          <Text style={[styles.meta, { color: theme.muted }]}>
            {available ? `${formatSize(available.bundle.size)} · downloaded` : "Downloaded"}
          </Text>

          <View style={styles.notes}>
            {notes.slice(0, 5).map((line, i) => (
              <View key={i} style={styles.noteRow}>
                <View style={[styles.dot, { backgroundColor: theme.accent }]} />
                <Text style={[styles.noteText, { color: theme.text }]}>{line}</Text>
              </View>
            ))}
          </View>

          <Pressable
            accessibilityRole="button"
            onPress={() => void ota.restart()}
            disabled={ota.busy}
            style={[styles.primary, { backgroundColor: theme.accent }]}
          >
            <Text style={styles.primaryText}>Restart to update</Text>
          </Pressable>
          <Pressable accessibilityRole="button" onPress={close} style={styles.secondary}>
            <Text style={[styles.secondaryText, { color: theme.muted }]}>Later</Text>
          </Pressable>
        </Pressable>
      </Pressable>
    </Modal>
  );
}

const styles = StyleSheet.create({
  backdrop: { flex: 1, justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.5)" },
  sheet: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 36, gap: 6 },
  handle: { alignSelf: "center", width: 40, height: 4, borderRadius: 2, backgroundColor: "rgba(127,127,127,0.4)", marginBottom: 10 },
  eyebrow: { fontSize: 11, fontWeight: "800", letterSpacing: 1 },
  title: { fontSize: 20, fontWeight: "800" },
  meta: { fontSize: 12 },
  notes: { gap: 8, marginTop: 12, marginBottom: 18 },
  noteRow: { flexDirection: "row", alignItems: "flex-start", gap: 10 },
  dot: { width: 6, height: 6, borderRadius: 3, marginTop: 7 },
  noteText: { flex: 1, fontSize: 14, lineHeight: 20 },
  primary: { borderRadius: 12, paddingVertical: 14, alignItems: "center" },
  primaryText: { color: "#fff", fontSize: 15, fontWeight: "700" },
  secondary: { paddingVertical: 12, alignItems: "center" },
  secondaryText: { fontSize: 14, fontWeight: "600" },
});

What's new dialog

My App

↑

What's new

Version 2.4.1

• Faster checkout

• Fixed a crash on login

• New dark mode icons

Restart nowNot now

Default theme

My App

↑

What's new

Version 2.4.1

• Faster checkout

• Fixed a crash on login

• New dark mode icons

Restart nowNot now

With a custom theme

UpdateDialog: a centered card with the release notes, Restart now and Not now.

A good fit when updates carry user-facing changes. Use it instead of the sheet by passing renderPrompt={(ota) => <UpdateDialog ota={ota} />}.

tsx
// src/UpdateDialog.tsx — a centered "What's new" dialog once an update is downloaded.
// Use it as renderPrompt: <FireOTAProvider renderPrompt={(ota) => <UpdateDialog ota={ota} />} …>
import React, { useState } from "react";
import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
import type { FireOTAContextValue } from "@fireota/react-native";

import { RequiredUpdateScreen, isRequiredUpdate } from "./RequiredUpdateScreen";

export function UpdateDialog({ ota }: { ota: FireOTAContextValue }) {
  // "Not now" hides the dialog for this update only; it still applies on the next cold start.
  const [dismissed, setDismissed] = useState<string | null>(null);
  const { theme, available } = ota;

  if (isRequiredUpdate(ota)) return <RequiredUpdateScreen ota={ota} />;

  const ready =
    ota.status === "installed" &&
    ota.pendingReleaseId !== null &&
    ota.pendingReleaseId !== ota.currentReleaseId &&
    dismissed !== ota.pendingReleaseId;
  if (!ready) return null;

  const notes = (available?.notes || available?.message || "Improvements and bug fixes.")
    .split("\n")
    .map((line) => line.replace(/^[-•*]\s*/, "").trim())
    .filter(Boolean);
  const close = () => setDismissed(ota.pendingReleaseId);

  return (
    <Modal visible transparent animationType="fade" statusBarTranslucent onRequestClose={close}>
      <View style={styles.backdrop}>
        <View style={[styles.dialog, { backgroundColor: theme.card }]}>
          <View style={[styles.icon, { backgroundColor: theme.accent }]}>
            <Text style={styles.iconText}>↑</Text>
          </View>
          <Text style={[styles.title, { color: theme.text }]}>What's new</Text>
          {available?.version ? (
            <Text style={[styles.version, { color: theme.muted }]}>Version {available.version}</Text>
          ) : null}

          <View style={styles.notes}>
            {notes.slice(0, 6).map((line, i) => (
              <Text key={i} style={[styles.note, { color: theme.text }]}>
                •  {line}
              </Text>
            ))}
          </View>

          <Pressable
            accessibilityRole="button"
            onPress={() => void ota.restart()}
            disabled={ota.busy}
            style={[styles.primary, { backgroundColor: theme.accent }]}
          >
            <Text style={styles.primaryText}>Restart now</Text>
          </Pressable>
          <Pressable accessibilityRole="button" onPress={close} style={styles.secondary}>
            <Text style={[styles.secondaryText, { color: theme.muted }]}>Not now</Text>
          </Pressable>
        </View>
      </View>
    </Modal>
  );
}

const styles = StyleSheet.create({
  backdrop: { flex: 1, alignItems: "center", justifyContent: "center", padding: 28, backgroundColor: "rgba(0,0,0,0.55)" },
  dialog: { width: "100%", maxWidth: 340, borderRadius: 20, padding: 22, alignItems: "center", gap: 4 },
  icon: { width: 48, height: 48, borderRadius: 24, alignItems: "center", justifyContent: "center", marginBottom: 8 },
  iconText: { color: "#fff", fontSize: 22, fontWeight: "800" },
  title: { fontSize: 19, fontWeight: "800" },
  version: { fontSize: 12, fontFamily: "monospace" },
  notes: { alignSelf: "stretch", gap: 6, marginTop: 14, marginBottom: 18 },
  note: { fontSize: 14, lineHeight: 20 },
  primary: { alignSelf: "stretch", borderRadius: 12, paddingVertical: 13, alignItems: "center" },
  primaryText: { color: "#fff", fontSize: 15, fontWeight: "700" },
  secondary: { paddingVertical: 11 },
  secondaryText: { fontSize: 14, fontWeight: "600" },
});

Required update screen

REQUIRED UPDATE

Updating your app

Fixes a payment issue. This only takes a moment.

Downloading…

Version 2.4.1

Downloading

REQUIRED UPDATE

Updating your app

Fixes a payment issue. This only takes a moment.

The update could not be downloaded. Check your connection.

Try again

Version 2.4.1

Download failed

RequiredUpdateScreen: blocks the app while a mandatory update downloads, with a retry if the download fails.

Shown for updates published with --mandatory. It cannot be dismissed — not even with Android's back button — and the app restarts by itself once the download is verified. Both prompts above already use it, so you only copy the file.

Caution: If you write your own prompt, keep the isRequiredUpdate(ota) check from this file. Without it a mandatory update is downloaded silently and users keep using the old version until the forced restart.
tsx
// src/RequiredUpdateScreen.tsx — full-screen notice while a mandatory update installs.
// The user cannot dismiss it: FireOTA restarts the app as soon as the download is verified.
import React from "react";
import { ActivityIndicator, Modal, Pressable, StyleSheet, Text, View } from "react-native";
import type { FireOTAContextValue } from "@fireota/react-native";

export function isRequiredUpdate(ota: FireOTAContextValue) {
  return Boolean(ota.available?.mandatory) && (ota.status === "downloading" || ota.status === "downloaded");
}

export function RequiredUpdateScreen({ ota }: { ota: FireOTAContextValue }) {
  const { theme, available } = ota;
  const failed = Boolean(ota.actionError ?? ota.error);

  return (
    // onRequestClose does nothing on purpose: Android's back button must not skip a required update.
    <Modal visible animationType="fade" statusBarTranslucent onRequestClose={() => {}}>
      <View style={[styles.screen, { backgroundColor: theme.background }]}>
        <View style={[styles.badge, { backgroundColor: theme.card }]}>
          <Text style={[styles.badgeText, { color: theme.accent }]}>REQUIRED UPDATE</Text>
        </View>
        <Text style={[styles.title, { color: theme.text }]}>Updating your app</Text>
        <Text style={[styles.body, { color: theme.muted }]}>
          {available?.message || available?.notes || "An important update is being installed. This only takes a moment."}
        </Text>

        {failed ? (
          <>
            <Text style={[styles.error, { color: theme.danger }]}>The update could not be downloaded. Check your connection.</Text>
            <Pressable
              accessibilityRole="button"
              onPress={() => void ota.install()}
              disabled={ota.busy}
              style={[styles.button, { backgroundColor: theme.accent }]}
            >
              <Text style={styles.buttonText}>{ota.busy ? "Retrying…" : "Try again"}</Text>
            </Pressable>
          </>
        ) : (
          <View style={styles.progress}>
            <ActivityIndicator color={theme.accent} size="large" />
            <Text style={[styles.hint, { color: theme.muted }]}>
              {ota.status === "downloaded" ? "Restarting…" : "Downloading…"}
            </Text>
          </View>
        )}

        {available?.version ? (
          <Text style={[styles.meta, { color: theme.muted }]}>Version {available.version}</Text>
        ) : null}
      </View>
    </Modal>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", padding: 32, gap: 12 },
  badge: { borderRadius: 999, paddingHorizontal: 12, paddingVertical: 5 },
  badgeText: { fontSize: 11, fontWeight: "800", letterSpacing: 1 },
  title: { fontSize: 24, fontWeight: "800", textAlign: "center" },
  body: { fontSize: 15, lineHeight: 21, textAlign: "center", maxWidth: 320 },
  progress: { alignItems: "center", gap: 10, marginTop: 16 },
  hint: { fontSize: 13 },
  error: { fontSize: 13, textAlign: "center", marginTop: 12 },
  button: { borderRadius: 12, paddingHorizontal: 28, paddingVertical: 13, marginTop: 4 },
  buttonText: { color: "#fff", fontSize: 15, fontWeight: "700" },
  meta: { position: "absolute", bottom: 40, fontSize: 12, fontFamily: "monospace" },
});

App updates settings card

Settings

App updates

Update available: 2.4.1

Faster checkout and a fix for a crash on login.

App version2.4.0

Channelproduction

Runningstore build

Last checkedToday, 9:41

Download update

Update available

Settings

App updates

You're on the latest version

App version2.4.0

Channelproduction

Runningupdate 3f9a12c0

Last checkedToday, 9:41

Check for updates

Up to date (custom theme)

UpdatesSettingsCard: version, channel and status, with one button that always does the next step.

Drop it into a Settings or About screen. The button changes with the state: Check for updates → Download update → Restart now. It works with withFireOTA too — no renderPrompt needed.

tsx
// src/screens/SettingsScreen.tsx
import { ScrollView } from "react-native";
import { UpdatesSettingsCard } from "../UpdatesSettingsCard";

export function SettingsScreen() {
  return (
    <ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
      {/* …your other settings… */}
      <UpdatesSettingsCard />
    </ScrollView>
  );
}
tsx
// src/UpdatesSettingsCard.tsx — an "App updates" card for your Settings or About screen.
// Works anywhere under withFireOTA / FireOTAProvider: <UpdatesSettingsCard />
import React from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
import { useFireOTA } from "@fireota/react-native";

export function UpdatesSettingsCard() {
  const ota = useFireOTA();
  const { theme, available } = ota;
  const pending = Boolean(ota.pendingReleaseId && ota.pendingReleaseId !== ota.currentReleaseId);
  const working = ota.busy || ota.status === "checking" || ota.status === "downloading";
  const error = ota.actionError ?? ota.error;

  const status = pending
    ? "Update downloaded — restart to apply"
    : available
      ? `Update available${available.version ? `: ${available.version}` : ""}`
      : ota.status === "upToDate"
        ? "You're on the latest version"
        : ota.status === "checking"
          ? "Checking for updates…"
          : "Not checked yet";

  // One button that always does the next sensible step.
  const action = pending
    ? { label: "Restart now", run: ota.restart }
    : available
      ? { label: "Download update", run: ota.install }
      : { label: "Check for updates", run: ota.check };

  const lastChecked = ota.lastCheckedAt ? new Date(ota.lastCheckedAt).toLocaleString() : "never";

  return (
    <View style={[styles.card, { backgroundColor: theme.card }]}>
      <View style={styles.header}>
        <Text style={[styles.title, { color: theme.text }]}>App updates</Text>
        <View style={[styles.dot, { backgroundColor: pending || available ? theme.accent : "#22C55E" }]} />
      </View>
      <Text style={[styles.status, { color: theme.text }]}>{status}</Text>
      {available && !pending && (available.notes || available.message) ? (
        <Text style={[styles.notes, { color: theme.muted }]} numberOfLines={3}>
          {available.notes || available.message}
        </Text>
      ) : null}

      <View style={[styles.rows, { borderColor: theme.background }]}>
        <Row label="App version" value={ota.appVersion || "—"} color={theme} />
        <Row label="Channel" value={ota.channel} color={theme} />
        <Row label="Running" value={ota.currentReleaseId ? `update ${ota.currentReleaseId.slice(0, 8)}` : "store build"} color={theme} />
        <Row label="Last checked" value={lastChecked} color={theme} />
      </View>

      {error ? <Text style={[styles.error, { color: theme.danger }]}>{error}</Text> : null}

      <Pressable
        accessibilityRole="button"
        onPress={() => void action.run()}
        disabled={working}
        style={[styles.button, { backgroundColor: theme.accent }]}
      >
        {working ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>{action.label}</Text>}
      </Pressable>
    </View>
  );
}

function Row({ label, value, color }: { label: string; value: string; color: { text: string; muted: string } }) {
  return (
    <View style={styles.row}>
      <Text style={[styles.rowLabel, { color: color.muted }]}>{label}</Text>
      <Text style={[styles.rowValue, { color: color.text }]} numberOfLines={1}>
        {value}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  card: { borderRadius: 16, padding: 16, gap: 6 },
  header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
  title: { fontSize: 17, fontWeight: "800" },
  dot: { width: 10, height: 10, borderRadius: 5 },
  status: { fontSize: 14, fontWeight: "600" },
  notes: { fontSize: 13, lineHeight: 18 },
  rows: { borderTopWidth: 1, marginTop: 8, paddingTop: 4 },
  row: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 7, gap: 12 },
  rowLabel: { fontSize: 13 },
  rowValue: { fontSize: 13, fontWeight: "600", flexShrink: 1, textAlign: "right" },
  error: { fontSize: 13 },
  button: { borderRadius: 12, paddingVertical: 13, alignItems: "center", marginTop: 8 },
  buttonText: { color: "#fff", fontSize: 15, fontWeight: "700" },
});

Test your screen

  1. 1
    Install a release build that contains the template.
  2. 2
    Publish to the beta channel with notes on separate lines: npx fireota publish … --channel beta --message "Faster checkout".
  3. 3
    Open the app and wait a few seconds — the sheet or dialog appears once the download finishes.
  4. 4
    Publish again with --mandatory to see the required-update screen.

Was this page helpful?