+1 (415) 943-4271

Home Screen Widgets in React Native: WidgetKit, Glance, and Sharing Data With Your JS App

Every few months a client asks the same question in a slightly different way: "Can we put a widget on the home screen?" The answer is yes — but not the way most teams expect. Widgets are one of the few mobile surfaces where React Native genuinely cannot follow you. There is no JavaScript runtime on the home screen, no bridge, no Hermes instance, and no React reconciler driving those pixels.

That does not mean it is a big native rewrite. On the projects we run, a widget is usually a few hundred lines of SwiftUI and Kotlin plus a disciplined data contract with the RN app. This post is the architecture we keep reaching for.

Why there is no <Widget /> component

On iOS, WidgetKit renders a static SwiftUI timeline in a separate extension process that the system launches, snapshots, and kills. Your app is not running. On Android, modern widgets use Glance, a Compose-flavoured API that emits RemoteViews rendered by the launcher process — again, not your process.

Two consequences follow, and they drive every decision below:

  1. The widget cannot call your JS code. Not your API client, not your Redux store, not your React Query cache.
  2. The widget must render from data that is already on disk, written by the app ahead of time, in a location both processes can read.

So the real work is not "drawing a widget". It is designing the hand-off.

Step 1: own your native targets without losing CNG

If you have already moved to Continuous Native Generation (no checked-in ios/ and android/), do not undo that to add a widget. Keep the targets in source and let the prebuild regenerate around them.

On iOS, the cleanest route today is expo-apple-targets: you keep a directory like targets/widget/ containing your SwiftUI files and an expo-target.config.js, and the plugin wires the extension, bundle identifier, entitlements, and build phases into the generated Xcode project.

// targets/widget/expo-target.config.js
module.exports = (config) => ({
  type: 'widget',
  name: 'StatusWidget',
  icon: '../../assets/widget-icon.png',
  entitlements: {
    'com.apple.security.application-groups': [
      `group.${config.ios.bundleIdentifier}.widget`,
    ],
  },
});

On Android there is no equivalent ecosystem plugin, so we write a small local config plugin that copies a widget/ source directory into android/app/src/main/java/..., drops the AppWidgetProvider/Glance receiver into the manifest, and adds the XML metadata:

// plugins/withAndroidWidget.js
const { withAndroidManifest, withDangerousMod } = require('expo/config-plugins');

module.exports = function withAndroidWidget(config) {
  config = withDangerousMod(config, ['android', copyWidgetSources]);
  return withAndroidManifest(config, (cfg) => {
    const app = cfg.modResults.manifest.application[0];
    app.receiver = app.receiver || [];
    app.receiver.push({
      $: { 'android:name': '.widget.StatusWidgetReceiver', 'android:exported': 'false' },
      'intent-filter': [{ action: [{ $: { 'android:name': 'android.appwidget.action.APPWIDGET_UPDATE' } }] }],
      'meta-data': [{ $: {
        'android:name': 'android.appwidget.provider',
        'android:resource': '@xml/status_widget_info',
      } }],
    });
    return cfg;
  });
};

The point of both is the same: the widget lives in your repo as source, and npx expo prebuild --clean still works. A checked-in ios/ folder added "just for the widget" is how teams quietly lose a year of upgradeability.

Step 2: the shared storage contract

This is the part worth arguing about in design review, because it is the part that leaks.

iOS: the app and the extension must share an App Group, and both read and write a suite-scoped UserDefaults:

let defaults = UserDefaults(suiteName: "group.com.example.app.widget")
let json = defaults?.string(forKey: "widget_state")

Android: a SharedPreferences file the Glance receiver reads directly, or Glance's own stateDefinition.

On the JS side, expose one tiny native module with one method. Resist the temptation to expose a generic key-value API — you will end up with five call sites writing five shapes.

// widget.ts
import { requireNativeModule } from 'expo-modules-core';

type WidgetState = {
  version: 1;
  headline: string;
  value: string;
  updatedAt: string; // ISO 8601
};

const Native = requireNativeModule('WidgetBridge');

export function publishWidgetState(state: WidgetState) {
  Native.setState(JSON.stringify(state));
  Native.reloadTimelines(); // WidgetCenter.shared.reloadAllTimelines() / AppWidgetManager.updateAppWidget
}

Three rules we enforce on that payload:

  • Version it. version: 1 in the blob. The widget extension is code from whatever app build the user last installed, but the data may have been written by an older or newer build mid-update. Unknown version → render the placeholder, never crash.
  • Pre-format everything. Currency, dates, pluralization, translated strings: format in JS, where your i18n stack lives, and store display-ready strings. Duplicating locale logic into SwiftUI and Kotlin is how the widget ends up saying "1 items".
  • Keep it small and non-sensitive. App Group storage is not a secure enclave, and widget snapshots are rendered on the lock screen. Never put tokens, account numbers, or anything you would not print on a billboard in there.

Step 3: refresh without burning your budget

The most common production bug is not a crash — it is a widget showing yesterday's number.

iOS gives each widget a limited number of timeline refreshes per day (the system decides, roughly dozens, weighted by how often the user actually looks at it). You cannot poll. So combine three triggers:

  1. App foreground/background writes. Every time the app has fresh data, publish state and reload timelines. This covers the engaged user for free.
  2. A timeline with a sensible cadence. Return entries an hour or more apart with .after(date) refresh policy rather than asking for every fifteen minutes and getting throttled.
  3. Silent push for genuinely event-driven data. If the number must be right within minutes, a content-available push that writes shared state and calls reloadAllTimelines() is the only reliable path. Budget it like any other background wake — see our notes on background work in React Native.

Always render a staleness affordance: if updatedAt is older than your tolerance, show a dimmed value or a small "updated 3h ago" line. Users forgive stale; they do not forgive wrong.

struct Provider: TimelineProvider {
  func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
    let entry = Entry(state: SharedStore.load())
    let next = Date().addingTimeInterval(60 * 60)
    completion(Timeline(entries: [entry], policy: .after(next)))
  }
}

Step 4: deep link back into the app

A widget that is not tappable is a poster. On iOS, attach .widgetURL(URL(string: "myapp://dashboard?source=widget")!) to the view, or Link on individual elements in larger families. On Android, set a PendingIntent from the Glance action.

Route those URLs through the same deep link handler the rest of your app uses — do not add a special case. Tag the source=widget parameter and send it to analytics; widget engagement is one of the easiest metrics to prove to a stakeholder who asked for the feature.

Step 5: testing and CI, which is where teams get surprised

  • The widget is a separate build target. Adding it changes your iOS build: a new extension to sign, a new provisioning profile, a new App Group capability on both the app ID and the extension ID. If your EAS credentials are managed, run a build early rather than discovering this on release day.
  • Bundle size and binary limits. Extensions carry their own copies of assets. Widgets should reference a handful of small images, not your design system's asset catalog.
  • You cannot E2E test it easily. Maestro drives your app, not the launcher. What you can and should test in JS is the state-publishing function: given app state, does publishWidgetState emit a valid, versioned, pre-formatted payload? Snapshot that contract. The SwiftUI/Compose layer then gets covered by previews and a manual checklist per widget family.
  • Test the empty and logged-out states. A widget on a device where the user signed out must render a neutral placeholder, not the last-known private data. This is a real privacy finding in security reviews; clear shared storage on logout.

What this costs

For a typical "one metric plus a tap target" widget on both platforms, we scope roughly a week: two to three days of native target and plugin work, a day for the shared storage module and its tests, a day on refresh tuning and the deep link, and a day of device matrix checking across widget families and Android launchers. Interactive widgets (iOS App Intents, Glance actions that mutate state without opening the app) roughly double it, because now the extension needs to write back and your app has to reconcile.

It is small, contained, high-visibility native work — exactly the kind of thing a React Native team can absorb, provided nobody tries to make it render JavaScript.


Adding a home screen surface to an existing React Native app, or untangling a widget that shows stale data? Get in touch — our React Native consultants do this alongside your team, and leave the native targets documented in your repo.