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.
- 1Copy the template file into your app's
src/folder. The prompts also needRequiredUpdateScreen.tsx. - 2Replace
withFireOTA(App, fireotaConfig)inindex.jswithFireOTAProviderand pass the template asrenderPrompt: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); - 3Build a release build once; after that, publish changes to the screen as normal updates.
--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
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
With a custom theme
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.
// 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
Default theme
My App
What's new
Version 2.4.1
• Faster checkout
• Fixed a crash on login
• New dark mode icons
With a custom theme
A good fit when updates carry user-facing changes. Use it instead of the sheet by passing renderPrompt={(ota) => <UpdateDialog ota={ota} />}.
// 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
Updating your app
Fixes a payment issue. This only takes a moment.
Downloading…
Version 2.4.1
Downloading
Updating your app
Fixes a payment issue. This only takes a moment.
The update could not be downloaded. Check your connection.
Try againVersion 2.4.1
Download failed
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.
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.// 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
Update available
Settings
App updates
You're on the latest version
App version2.4.0
Channelproduction
Runningupdate 3f9a12c0
Last checkedToday, 9:41
Up to date (custom theme)
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.
// 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>
);
}// 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
- 1Install a release build that contains the template.
- 2Publish to the beta channel with notes on separate lines:
npx fireota publish … --channel beta --message "Faster checkout". - 3Open the app and wait a few seconds — the sheet or dialog appears once the download finishes.
- 4Publish again with
--mandatoryto see the required-update screen.
Was this page helpful?