Hello! In our last lesson, we built the core logic for manual Over-the-Air updates by creating the useAppUpdater hook. This hook successfully checks for updates, downloads them in the background, and gives us the isUpdateAvailable flag and applyUpdate function we need. We ended with a basic placeholder button to prove it works.
Now, it's time to replace that placeholder with a polished, user-facing component. This is a crucial step in creating a professional mobile app experience. We don't want to force updates on users silently, nor do we want to be overly intrusive. The goal is to inform them gracefully and give them control.
In this lesson, you will design and implement a user-facing notification component for pending OTA updates. We will explore different UI patterns, choose the best one for our use case, and integrate it into your React app using your existing shadcn/ui component library. By the end, you'll have a complete, user-friendly update flow from detection to application.
1. Choosing the Right Notification Pattern
Before writing any code, it's important to consider the user experience. How should we tell the user an update is ready? A poorly chosen notification can be annoying or, even worse, easily missed.

Let's analyze these options for a non-critical, "user-choice" update:
- Modal Dialog (Pop-up): This is the most intrusive option. It demands immediate attention and blocks all other interaction. While its high visibility ensures the user sees the message, it can be frustrating, especially if the user is in the middle of a task. This pattern is best reserved for forced updates where the app cannot continue without the new version.
- Banner Notification: A banner at the top or bottom of the screen is much less disruptive. The user can continue using the app and interact with the notification at their convenience. Its main drawback is that it's easier to ignore.
- In-App Message / Toast: This is similar to a banner but often appears temporarily as a "toast" notification. It's excellent for providing timely, contextual information without hijacking the user's entire screen. Your
shadcn/uilibrary includes a sophisticated toast component that is perfect for this. It's non-blocking but noticeable.
For our goal of providing an optional update, a toast notification strikes the right balance. It effectively informs the user without forcing an interruption.
Effective App Update Notification Strategies - Capgo
This article from Capgo provides excellent guidance on this topic. It reinforces the principle of choosing the least disruptive pattern that achieves the goal.
Focus on the section comparing the main patterns. Note the advice: "A subtle banner often does more work than a dramatic modal because it doesn’t force the user to fight the interface." This supports our choice of a toast/banner-like component.
2. Implementing the Update Toast with shadcn/ui
Your project already uses shadcn/ui, which provides a Toast component built on the excellent react-toast-sonner library. We can use this to create a rich notification with interactive elements, like an "Update Now" button.
First, ensure you have a <Toaster /> component rendered at the root of your application layout (e.g., in your main App.tsx or a layout component). This component acts as the container where all toasts will be rendered.
// e.g., in packages/web/src/App.tsx
import { Toaster } from "@/components/ui/toaster" // Assuming shadcn path alias
function App() {
// ...
return (
<>
{/* Your app content */}
<Toaster />
</>
);
}
With the Toaster in place, you can trigger a notification from anywhere in your app by importing and calling the toast function. The real power comes from embedding custom React elements within it using the action prop.
Let's create a new component to encapsulate our notification logic. This keeps the App.tsx file clean.
Create packages/web/src/components/UpdateNotifier.tsx:
// packages/web/src/components/UpdateNotifier.tsx
'use client'; // Required for hooks
import { useEffect } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { ToastAction } from '@/components/ui/toast';
import { useAppUpdater } from '@/hooks/useAppUpdater';
export function UpdateNotifier() {
const { toast } = useToast();
const { isUpdateAvailable, applyUpdate } = useAppUpdater();
useEffect(() => {
if (isUpdateAvailable) {
toast({
title: 'Update Available',
description: 'A new version is ready to be installed.',
// Persist the toast until manually dismissed or action is taken
duration: Infinity,
action: (
<ToastAction altText="Install & Restart" onClick={applyUpdate}>
Install & Restart
</ToastAction>
),
});
}
}, [isUpdateAvailable, applyUpdate, toast]);
return null; // This component renders no direct UI, only triggers toasts
}
Now, simply include this <UpdateNotifier /> component within your main App.tsx file, inside the component tree but before the <Toaster />.
// e.g., in packages/web/src/App.tsx
import { Toaster } from "@/components/ui/toaster"
import { UpdateNotifier } from "@/components/UpdateNotifier"
function App() {
// ...
return (
<>
{/* Your app content */}
<UpdateNotifier />
<Toaster />
</>
);
}
With this setup, whenever the useAppUpdater hook sets isUpdateAvailable to true, our useEffect in UpdateNotifier will trigger, displaying a persistent toast with a clickable "Install & Restart" button. Clicking this button executes the applyUpdate function from our hook, which reloads the app with the new bundle.
3. Improving the UX: Avoiding Nagging
There's a significant UX flaw in our current implementation. The useAppUpdater hook checks for an update on every app launch. If a user sees the toast and decides to ignore it (by clicking the 'X' to dismiss it), the toast will reappear the next time they open the app. This can quickly become annoying.
We need to respect the user's choice to dismiss the notification. The solution is to persist the dismissal state.
Effective App Update Notification Strategies - Capgo
This problem is common enough that the Capgo article dedicates a section to it.
Read the section Troubleshooting Common Notification Issues. The recommended fix is exactly what we will implement: storing the dismissed version and checking against it.
We will enhance our useAppUpdater hook to handle this. The new logic will be:
- When an update is downloaded, its version identifier is stored in state.
- If the user dismisses the toast, we'll save that version identifier to
localStorage. - The hook will only consider an update "prompt-worthy" if it's new and its version has not been previously dismissed.
Let's modify useAppUpdater.ts to expose more control.
Updated useAppUpdater Hook
// packages/web/src/hooks/useAppUpdater.ts
import { useEffect, useState, useCallback } from 'react';
import { CapacitorUpdater, type BundleInfo } from '@capgo/capacitor-updater';
const DISMISSED_VERSION_KEY = 'dismissedUpdateVersion';
export const useAppUpdater = () => {
const [updateInfo, setUpdateInfo] = useState<BundleInfo | null>(null);
const [showUpdatePrompt, setShowUpdatePrompt] = useState(false);
// 1. Initial check on component mount
useEffect(() => {
CapacitorUpdater.notifyAppReady();
const check = async () => {
try {
const latest = await CapacitorUpdater.getLatest();
if (latest.version) {
const dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
// Only proceed if the available version has not been dismissed
if (latest.version !== dismissedVersion) {
console.log(`Update available: ${latest.version}`);
const bundle = await CapacitorUpdater.download(latest);
setUpdateInfo(bundle);
setShowUpdatePrompt(true); // Signal that a prompt should be shown
} else {
console.log(`Update ${latest.version} was previously dismissed.`);
}
} else {
console.log('No new updates available.');
}
} catch (error) {
console.error('Update check or download failed:', error);
}
};
check();
}, []);
// 2. Function to apply the update
const applyUpdate = useCallback(async () => {
if (updateInfo) {
console.log('Applying update...');
setShowUpdatePrompt(false); // Hide prompt immediately
await CapacitorUpdater.set({ id: updateInfo.id });
await CapacitorUpdater.reload();
}
}, [updateInfo]);
// 3. Function to dismiss the update prompt
const dismissUpdate = useCallback(() => {
if (updateInfo) {
console.log(`Dismissing update: ${updateInfo.version}`);
localStorage.setItem(DISMISSED_VERSION_KEY, updateInfo.version);
setShowUpdatePrompt(false);
}
}, [updateInfo]);
return {
showUpdatePrompt,
applyUpdate,
dismissUpdate,
};
};
Updated UpdateNotifier Component
Now, we update our UpdateNotifier component to use the new state and functions from the hook. The shadcn/ui toast component conveniently provides onDismiss and onAutoClose callbacks that we can use to trigger our dismissUpdate function.
// packages/web/src/components/UpdateNotifier.tsx
'use client';
import { useEffect, useRef } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { ToastAction } from '@/components/ui/toast';
import { useAppUpdater } from '@/hooks/useAppUpdater';
export function UpdateNotifier() {
const { toast } = useToast();
const { showUpdatePrompt, applyUpdate, dismissUpdate } = useAppUpdater();
// Use a ref to prevent showing the toast multiple times in a single session
const toastShownRef = useRef(false);
useEffect(() => {
// Only show the toast if the hook signals to and we haven't shown it already
if (showUpdatePrompt && !toastShownRef.current) {
toastShownRef.current = true; // Mark as shown for this session
toast({
title: 'Update Available',
description: 'A new version is ready to be installed.',
duration: Infinity,
action: (
<ToastAction altText="Install & Restart" onClick={applyUpdate}>
Install & Restart
</ToastAction>
),
// Handle explicit dismissal (clicking 'X')
onDismiss: () => {
if (toastShownRef.current) { // Only dismiss if it was the one we showed
dismissUpdate();
}
},
});
}
}, [showUpdatePrompt, applyUpdate, dismissUpdate, toast]);
// This component still renders no direct UI
return null;
}
This revised implementation is much more robust. It checks for an update, verifies it hasn't been dismissed, and then signals the UI to show a prompt. The prompt itself is now aware of dismissal actions and correctly persists the user's choice, providing a much better user experience.
Conclusion
In this lesson, you successfully built a complete, user-friendly notification system for your OTA updates. You've gone beyond the basic mechanics and considered the user experience, resulting in a professional and respectful implementation.
Here are the key takeaways:
- You analyzed different UI patterns and chose a toast notification as the most suitable option for a non-critical update.
- You implemented this pattern using your existing
shadcn/uicomponent library, leveraging itsToastcomponent to display a message and an interactive button. - You connected this UI to the
useAppUpdaterhook from the previous lesson, allowing the user to trigger the update. - Most importantly, you refined the system to prevent nagging by persisting the user's dismissal choice in
localStorage, ensuring the prompt doesn't reappear on every launch for the same version.
You now have a fully functional OTA update mechanism, from publishing a bundle on the server to prompting the user and applying it on the client. The core functionality of your wrapped application is taking shape.
With the app's features and update system in place, our next focus will be on the final phase: distribution. In the next module, "Preparing for Store Distribution," we will begin the formal process of getting your app ready for the public. This involves creating application records in App Store Connect and the Google Play Console, configuring release builds, and preparing the necessary metadata and privacy information.
Can't find a good explanation? Sign up and we'll make it for you
Sign up