building them for iOS and Android
React Native widgets are not rendered by the JavaScript engine, they are rendered by each platform's own native widget framework. This article shows how an iOS Widget Extension target built with SwiftUI and an Android App Widget built with Jetpack Glance connect cleanly to an existing React Native app, including data exchange and deep linking.
Table of Contents
- 1. Why widgets are not part of the React Native JS engine
- 2. iOS Widget Extension: an Xcode target next to the RN app
- 3. The SwiftUI widget view and TimelineProvider
- 4. Android App Widget with Jetpack Glance
- 5. Data exchange: App Groups and SharedPreferences
- 6. Expo, config plugins and a custom dev client
- 7. Triggering a widget refresh from JavaScript
- 8. Deep linking from the widget back into the app
- 9. WidgetKit versus Jetpack Glance in comparison
- 10. Summary
- 11. FAQ
1. Why widgets are not part of the React Native JS engine
The most common misconception when building React Native widgets is assuming a widget is simply a smaller React Native view. The opposite is true: home screen widgets run in their own process, separate from the app's main process, and are rendered exclusively by the native widget frameworks, WidgetKit with SwiftUI on iOS from version 14, Jetpack Glance or classic RemoteViews on Android. React Native's JavaScript engine, whether Hermes or JSC, is never started for the widget itself.
This separation exists for a good reason: widgets need to be extremely resource efficient, since the operating system refreshes them regularly without any user interaction. A full React Native process with a JS bridge would be too heavyweight for that update cycle and would drain the battery unnecessarily. Instead, the operating system renders a declarative view description that gets drawn at the right moment by the system itself, not by a running app instance.
For a React Native project this means concretely: a widget always requires native Xcode and Android Studio knowledge, on top of the existing JavaScript code. The art lies in keeping the native widget logic as lean as possible and providing all the data the widget should display through a defined channel from the main app, instead of duplicating business logic inside the widget itself.
2. iOS Widget Extension: an Xcode target next to the RN app
The first step on iOS is an additional Xcode target of type Widget Extension, existing alongside the existing React Native app target. This target has its own Info.plist, its own Swift files, and gets compiled as a separate binary when building the app, but packaged together into one app bundle. It is important that this target is assigned the same App Group as the main app, otherwise no data exchange is possible later.
Since React Native projects are typically managed through CocoaPods or, more recently, without it, the new widget target has to be wired into the existing Podfile configuration and the RN project's Xcode build phases, without losing it on every rebuild from the auto-generated files of expo prebuild or react-native init. That configuration therefore either lives in its own versioned Xcode project patch, or in an Expo config plugin that automatically restores the extension folder on every prebuild.
# Typical structure after adding an iOS Widget Extension target
# ios/
# MyApp.xcodeproj
# MyApp/ <- main React Native app target
# MyAppWidget/ <- new Widget Extension target
# MyAppWidget.swift
# Info.plist
# Assets.xcassets
# Build the app including the widget extension for a device
xcodebuild -workspace ios/MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-destination "generic/platform=iOS" \
build
3. The SwiftUI widget view and TimelineProvider
The heart of every iOS widget is the TimelineProvider, which tells the system when to show which state of the widget. Unlike a normal app view that reacts to user interaction, the TimelineProvider supplies a timeline of entries ahead of time, which the system displays at the right moments even while the app is not running. For data that changes unpredictably, such as an order status or a live event, this is combined with an explicit reload trigger from the app.
The widget's actual SwiftUI view stays deliberately simple: it reads exclusively from the TimelineEntry the provider supplies and contains no network logic of its own. That keeps the widget quick to render and prevents a hanging network call from blocking the widget update, which the system would cut off anyway after a tight time budget.
import WidgetKit
import SwiftUI
struct OrderStatusEntry: TimelineEntry {
let date: Date
let statusText: String
}
struct OrderStatusProvider: TimelineProvider {
func placeholder(in context: Context) -> OrderStatusEntry {
OrderStatusEntry(date: Date(), statusText: "Loading...")
}
func getSnapshot(in context: Context, completion: @escaping (OrderStatusEntry) -> Void) {
completion(OrderStatusEntry(date: Date(), statusText: readSharedStatus()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<OrderStatusEntry>) -> Void) {
let entry = OrderStatusEntry(date: Date(), statusText: readSharedStatus())
// Refresh again in 30 minutes at the latest
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
struct OrderStatusWidgetView: View {
var entry: OrderStatusProvider.Entry
var body: some View {
Text(entry.statusText)
.font(.headline)
.widgetURL(URL(string: "myapp://orders/latest"))
}
}
4. Android App Widget with Jetpack Glance
On Android, an AppWidgetProvider and a declaration in AndroidManifest.xml form the frame of every app widget. Traditionally widgets were built with RemoteViews and XML layouts, a cumbersome process since RemoteViews only supports a heavily restricted subset of Android views. Jetpack Glance solves this by providing a Compose-like, declarative API style that still compiles down to RemoteViews internally, but is considerably nicer to write.
The AppWidgetProvider handles the lifecycle: it gets called by the system when the widget is added, updated and removed, and, similar to the iOS TimelineProvider, reads exclusively from an already prepared data store, not from a live network call. Update frequency is configured through android:updatePeriodMillis in the widget provider XML, though the system enforces a minimum of roughly thirty minutes for battery reasons, shorter intervals require an explicit trigger from the app.
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent
import androidx.glance.text.Text
import androidx.glance.action.clickable
import androidx.glance.action.actionStartActivity
class OrderStatusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val status = readSharedStatus(context)
Text(
text = status,
modifier = GlanceModifier.clickable(
actionStartActivity<MainActivity>(
// Deep link back into the app's order screen
actionParametersOf(deepLinkKey to "myapp://orders/latest")
)
)
)
}
}
}
class OrderStatusWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = OrderStatusWidget()
}
5. Data exchange: App Groups and SharedPreferences
Since the widget process and the app process run separately, React Native widgets need an explicit channel to exchange data. On iOS, App Groups handle this: both the main app target and the widget extension target are assigned to the same App Group, which lets both access a shared UserDefaults(suiteName:) area or a shared file container. The React Native side writes into this shared storage through a native module, and the widget reads from it on its next timeline refresh.
On Android, either a shared SharedPreferences namespace or, for more structured data, a dedicated content provider plays the same role. Important on both platforms: the data shown in the widget should be as small and already fully prepared as possible, for example a formatted status text instead of a raw JSON object, so the widget itself needs no extra parsing or formatting logic and renders quickly.
6. Expo, config plugins and a custom dev client
A pure Expo managed project cannot contain widgets, since they require native Xcode and Android Studio project knowledge that the standard Expo Go client does not bring along. The solution is an Expo config plugin that automatically generates the widget extension files and the necessary Xcode project changes during expo prebuild, combined with a custom dev client that actually includes this native extension.
Libraries like react-native-android-widget further reduce Kotlin boilerplate by allowing the widget layout to be described directly from React Native JSX, translated into native views at build time. That works well for simple, static layouts, but does not replace the need to fundamentally understand App Groups, TimelineProvider or AppWidgetProvider once more complex update logic is required.
{
"expo": {
"plugins": [
[
"./plugins/withIosWidget",
{ "appGroupIdentifier": "group.com.example.myapp" }
],
[
"react-native-android-widget",
{ "widgets": [{ "name": "OrderStatusWidget", "minWidth": "150dp" }] }
]
]
}
}
7. Triggering a widget refresh from JavaScript
As soon as relevant data changes in the app, for example a new order status arrives, the widget should reflect that change as promptly as possible instead of waiting for the next automatic update cycle. On iOS this happens through WidgetCenter.shared.reloadAllTimelines(), called from a native module addressed by JavaScript over the bridge. This method immediately requests a new timeline entry from the system instead of waiting for the next natural update window.
On Android, the app sends an explicit broadcast to the AppWidgetManager, which in turn prompts the AppWidgetProvider to redraw immediately. Both mechanisms should be used sparingly: a refresh on every single app open is unnecessary, a sensible trigger is exactly when the data shown in the widget has actually changed, for example after a successful background network sync.
// widgetBridge.ts — trigger native widget refresh from JS after data changes
import { NativeModules, Platform } from 'react-native';
const { WidgetBridge } = NativeModules;
export async function syncOrderStatusToWidget(statusText: string) {
// Write the formatted status into the shared storage (App Group / SharedPreferences)
await WidgetBridge.writeSharedStatus(statusText);
// Ask the OS to redraw the widget as soon as possible
if (Platform.OS === 'ios') {
WidgetBridge.reloadAllTimelines();
} else {
WidgetBridge.requestWidgetUpdate();
}
}
8. Deep linking from the widget back into the app
A widget you can tap without anything happening misses much of its purpose. On iOS this is solved through widgetURL() in the SwiftUI view, assigning a URL to the entire widget or individual areas that gets opened through the app's URL scheme or universal links on tap. On Android, actionStartActivity combined with an intent that passes the same URL as a deep link to the main activity plays the same role.
It is important that these deep link URLs address exactly the same routes that also exist in the regular app navigation system, for example configured through React Navigation or Expo Router. That way, after tapping the widget, a user lands directly on the relevant order or detail view, instead of the app's home screen, from which they would have to navigate again.
9. WidgetKit versus Jetpack Glance in comparison
Even though both platforms pursue conceptually similar goals, WidgetKit and Jetpack Glance differ in important technical details that directly affect the implementation effort for React Native widgets.
| Dimension | iOS WidgetKit | Android Jetpack Glance |
|---|---|---|
| Rendering technology | SwiftUI views driven by a TimelineProvider | Compose-like API, compiled to RemoteViews |
| Update mechanism | Timeline with scheduled entries, plus manual reload | updatePeriodMillis plus manual broadcast |
| Data exchange with the app | App Groups with shared UserDefaults | SharedPreferences or a dedicated content provider |
| Deep linking | widgetURL per view or region | actionStartActivity with intent extras |
| Minimum version | iOS 14 for WidgetKit, iOS 17 for interactive widgets | Android 12 for full Material You support in Glance |
For a React Native team this comparison mostly means: the native code for iOS and Android stays entirely separate, there is no shared widget codebase across both platforms. What can be shared is only the data preparation on the app side, which then flows through platform-specific bridges into the respective native widget implementation.
Mironsoft
React Native development, native extensions and platform-specific integrations
Home screen widgets for your React Native app?
We build iOS Widget Extensions with SwiftUI and Android App Widgets with Jetpack Glance, connected cleanly to your existing React Native app through App Groups, deep linking and automated refresh triggers.
iOS WidgetKit
SwiftUI widgets with a TimelineProvider and App Group integration
Android Glance
App widgets with Jetpack Glance and reliable refresh handling
Expo integration
Config plugins and a custom dev client for widget support
10. Summary
React Native widgets are not built in JavaScript, they are built in each platform's native widget framework: WidgetKit with SwiftUI and a TimelineProvider on iOS, Jetpack Glance with an AppWidgetProvider on Android. Both worlds require their own Xcode or Android Studio build process, exchanging data with the React Native main app through App Groups and SharedPreferences instead of running their own network logic inside the widget.
For Expo projects, config plugins and a custom dev client are the pragmatic path to integrating native widget extensions without fully leaving the Expo workflow. The decisive success factor stays the same though: lean, already fully prepared data inside the widget itself, sensible refresh triggering from the app, and working deep linking back into the correct view of the application.
React Native Widgets — The Essentials at a Glance
Separate process
Widgets run without the React Native JS engine, rendered directly by WidgetKit or Jetpack Glance.
Data exchange
App Groups on iOS, SharedPreferences or a content provider on Android, for small, ready-made data.
Refresh triggers
WidgetCenter.reloadAllTimelines and an AppWidgetManager broadcast instead of constant polling.
Deep linking
widgetURL and intent extras lead to exactly the right view in the app.