Tutorial

Build Custom OTA Update UI in React Native with SwiftPatch

How to build custom OTA update UI in React Native — modals, banners, progress bars, and mandatory update screens. Complete guide with code examples using SwiftPatch hooks.

S
SwiftPatch Team
Engineering
10 min read

Why Custom Update UI Matters

Default OTA update behavior is silent — the update downloads in the background and applies on next restart. That works for minor fixes, but sometimes you need to control the user experience:

  • Mandatory updates: Force users to update before using the app (security patches, breaking API changes)
  • Feature announcements: Show users what's new with a custom modal
  • Download progress: Show a progress bar for larger updates on slow connections
  • Update banners: Non-intrusive notification that an update is available

SwiftPatch gives you full control over the update UI through its React hooks API. Here's how to build each pattern.

Basic Setup

First, make sure SwiftPatch is initialized in your app:

// App.tsx
import { SwiftPatch } from 'swiftpatch';

SwiftPatch.init({
  deploymentKey: 'YOUR_KEY',
  checkFrequency: 'ON_APP_RESUME',
  installMode: 'ON_NEXT_RESTART',
  autoRollback: true,
});

Pattern 1: Optional Update Modal

A polite modal that lets users choose when to update:

import React, { useState, useEffect } from 'react';
import { View, Text, Modal, TouchableOpacity, StyleSheet } from 'react-native';
import { SwiftPatch } from 'swiftpatch';

function UpdateModal() {
  const [updateAvailable, setUpdateAvailable] = useState(false);
  const [updateInfo, setUpdateInfo] = useState<any>(null);

  useEffect(() => {
    checkForUpdates();
  }, []);

  async function checkForUpdates() {
    const update = await SwiftPatch.checkForUpdate();
    if (update && !update.isMandatory) {
      setUpdateInfo(update);
      setUpdateAvailable(true);
    }
  }

  async function handleUpdate() {
    setUpdateAvailable(false);
    await SwiftPatch.downloadUpdate();
    SwiftPatch.applyUpdate({ installMode: 'ON_NEXT_RESTART' });
  }

  return (
    <Modal visible={updateAvailable} transparent animationType="fade">
      <View style={styles.overlay}>
        <View style={styles.modal}>
          <Text style={styles.title}>Update Available</Text>
          <Text style={styles.description}>
            A new version is available with bug fixes and improvements.
          </Text>
          <View style={styles.buttons}>
            <TouchableOpacity
              style={styles.laterButton}
              onPress={() => setUpdateAvailable(false)}
            >
              <Text style={styles.laterText}>Later</Text>
            </TouchableOpacity>
            <TouchableOpacity
              style={styles.updateButton}
              onPress={handleUpdate}
            >
              <Text style={styles.updateText}>Update Now</Text>
            </TouchableOpacity>
          </View>
        </View>
      </View>
    </Modal>
  );
}

Pattern 2: Mandatory Update Screen

A full-screen blocker for critical updates — users can't dismiss this:

import React, { useState, useEffect } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { SwiftPatch } from 'swiftpatch';

function MandatoryUpdateScreen() {
  const [isMandatory, setIsMandatory] = useState(false);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState('Checking for updates...');

  useEffect(() => {
    handleMandatoryUpdate();
  }, []);

  async function handleMandatoryUpdate() {
    const update = await SwiftPatch.checkForUpdate();

    if (update && update.isMandatory) {
      setIsMandatory(true);
      setStatus('Downloading update...');

      await SwiftPatch.downloadUpdate({
        onProgress: (downloaded, total) => {
          setProgress(downloaded / total);
          setStatus(`Downloading... ${Math.round((downloaded / total) * 100)}%`);
        },
      });

      setStatus('Installing update...');
      SwiftPatch.applyUpdate({ installMode: 'IMMEDIATE' });
    }
  }

  if (!isMandatory) return null;

  return (
    <View style={styles.fullScreen}>
      <View style={styles.content}>
        <Text style={styles.title}>Required Update</Text>
        <Text style={styles.subtitle}>
          Please wait while we install an important update.
        </Text>

        {/* Progress bar */}
        <View style={styles.progressTrack}>
          <View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
        </View>

        <Text style={styles.statusText}>{status}</Text>
        <ActivityIndicator size="large" color="#007AFF" />
      </View>
    </View>
  );
}

Pattern 3: Non-Intrusive Banner

A small banner at the top or bottom of the screen:

import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native';
import { SwiftPatch } from 'swiftpatch';

function UpdateBanner() {
  const [visible, setVisible] = useState(false);
  const slideAnim = useState(new Animated.Value(-60))[0];

  useEffect(() => {
    checkUpdate();
  }, []);

  async function checkUpdate() {
    const update = await SwiftPatch.checkForUpdate();
    if (update && !update.isMandatory) {
      setVisible(true);
      Animated.spring(slideAnim, {
        toValue: 0,
        useNativeDriver: true,
      }).start();
    }
  }

  async function handleUpdate() {
    await SwiftPatch.downloadUpdate();
    SwiftPatch.applyUpdate({ installMode: 'IMMEDIATE' });
  }

  function dismiss() {
    Animated.timing(slideAnim, {
      toValue: -60,
      duration: 200,
      useNativeDriver: true,
    }).start(() => setVisible(false));
  }

  if (!visible) return null;

  return (
    <Animated.View
      style={[
        styles.banner,
        { transform: [{ translateY: slideAnim }] },
      ]}
    >
      <Text style={styles.bannerText}>New update available</Text>
      <View style={styles.bannerActions}>
        <TouchableOpacity onPress={handleUpdate}>
          <Text style={styles.updateLink}>Update</Text>
        </TouchableOpacity>
        <TouchableOpacity onPress={dismiss}>
          <Text style={styles.dismissLink}>Dismiss</Text>
        </TouchableOpacity>
      </View>
    </Animated.View>
  );
}

Pattern 4: Download Progress with Cancel

For larger updates, give users control:

import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { SwiftPatch } from 'swiftpatch';

function DownloadProgress() {
  const [downloading, setDownloading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [downloadSize, setDownloadSize] = useState('');

  async function startDownload() {
    setDownloading(true);

    await SwiftPatch.downloadUpdate({
      onProgress: (downloaded, total) => {
        setProgress(downloaded / total);
        setDownloadSize(`${(downloaded / 1024).toFixed(0)}KB / ${(total / 1024).toFixed(0)}KB`);
      },
    });

    setDownloading(false);
    // Apply on next restart
    SwiftPatch.applyUpdate({ installMode: 'ON_NEXT_RESTART' });
  }

  return (
    <View style={styles.container}>
      {downloading ? (
        <>
          <View style={styles.progressTrack}>
            <View
              style={[styles.progressFill, { width: `${progress * 100}%` }]}
            />
          </View>
          <Text style={styles.sizeText}>{downloadSize}</Text>
        </>
      ) : (
        <TouchableOpacity style={styles.downloadButton} onPress={startDownload}>
          <Text style={styles.buttonText}>Download Update</Text>
        </TouchableOpacity>
      )}
    </View>
  );
}

Pattern 5: "What's New" Changelog

Show users what changed in the update:

import React, { useState, useEffect } from 'react';
import { View, Text, Modal, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
import { SwiftPatch } from 'swiftpatch';

function WhatsNewModal() {
  const [showModal, setShowModal] = useState(false);
  const [releaseNotes, setReleaseNotes] = useState('');

  useEffect(() => {
    checkForUpdates();
  }, []);

  async function checkForUpdates() {
    const update = await SwiftPatch.checkForUpdate();
    if (update) {
      setReleaseNotes(update.description || 'Bug fixes and improvements.');
      setShowModal(true);
    }
  }

  async function handleUpdate() {
    setShowModal(false);
    await SwiftPatch.downloadUpdate();
    SwiftPatch.applyUpdate({ installMode: 'IMMEDIATE' });
  }

  return (
    <Modal visible={showModal} transparent animationType="slide">
      <View style={styles.overlay}>
        <View style={styles.whatsNewModal}>
          <Text style={styles.whatsNewTitle}>What's New</Text>
          <ScrollView style={styles.notesContainer}>
            <Text style={styles.releaseNotes}>{releaseNotes}</Text>
          </ScrollView>
          <TouchableOpacity style={styles.updateButton} onPress={handleUpdate}>
            <Text style={styles.updateButtonText}>Update & Restart</Text>
          </TouchableOpacity>
        </View>
      </View>
    </Modal>
  );
}

Best Practices

1. Don't Block the User Unnecessarily

Only use mandatory updates for critical security patches or breaking API changes. For everything else, use optional modals or non-intrusive banners.

2. Show Progress for Updates Over 500KB

Even though SwiftPatch patches are typically tiny (~200KB), show a progress indicator for any download that might take more than a second on slow connections.

3. Test All UI States

  • No update available (UI should be hidden)
  • Optional update available
  • Mandatory update available
  • Download in progress
  • Download failed (network error)
  • Update applied, awaiting restart

4. Handle Network Errors Gracefully

try {
  await SwiftPatch.downloadUpdate();
} catch (error) {
  // Show retry button, don't crash the app
  setError('Download failed. Check your connection.');
}

5. Respect User Choice

If a user dismisses an optional update, don't show it again immediately. Use a cooldown period:

const COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours

async function checkWithCooldown() {
  const lastDismissed = await AsyncStorage.getItem('update_dismissed_at');
  if (lastDismissed && Date.now() - parseInt(lastDismissed) < COOLDOWN_MS) {
    return; // Don't show again yet
  }
  // ... check for updates
}

SwiftPatch vs Stallion: Custom UI Comparison

Both SwiftPatch and Stallion support custom update UIs, but the APIs differ:

FeatureSwiftPatchStallion
Update checkSwiftPatch.checkForUpdate()useStallion().checkUpdate()
Download with progressdownloadUpdate({ onProgress })Limited callback support
Mandatory flagupdate.isMandatoryupdate.isMandatory
Apply modesIMMEDIATE, ON_NEXT_RESTART, ON_NEXT_RESUMEIMMEDIATE, ON_RESTART
Release notesupdate.descriptionLimited
Cancel downloadSupportedNot documented

SwiftPatch provides more granular control over the update flow, including download progress callbacks and multiple apply modes.

Conclusion

Custom OTA update UI is essential for providing a great user experience. With SwiftPatch's React hooks API, you can build:

  • Optional update modals that respect user choice
  • Mandatory update screens with progress bars
  • Non-intrusive banners with smooth animations
  • Download progress with cancel support
  • "What's New" changelogs that engage users

The key is matching the UI to the update urgency — silent for minor fixes, visible for features, and blocking only for critical security patches.

Get started with SwiftPatch and build custom update UIs →

Ready to ship updates faster?

Get started with SwiftPatch for free. No credit card required.

Join Waitlist

Related Articles