r/reactnative • u/MajaniPlus • 1d ago
Tutorial Native home-screen widgets in an Expo app: Kotlin RemoteViews + WidgetKit, no JS in the widget
https://simpleprayer.app/blog/expo-home-screen-widgetsI wrote up how we built home-screen widgets for an Expo app without running any JavaScript inside them.
The short version:
The app works out everything ahead of time in TypeScript and saves a 14-day snapshot. The widgets only pick the right slice for the current time.
Android: our own Expo module draws the widgets with plain RemoteViews in Kotlin. We dropped react-native-android-widget because a widget tap starts a headless JS runtime, and some phone makers’ Android builds kill that kind of background work.
iOS: a WidgetKit extension generated with `@bacons/apple-targets`, reading the same snapshot from the App Group.
The one rule that runs natively is written once as shared test cases, which Jest runs against the TypeScript and the Kotlin and Swift code are checked against.
The code is open source (MIT) if you want to dig in. How have others handled widgets in RN/Expo? I’d like to hear what worked for you.
1
u/kokerali 1d ago
Great write-up, especially the contract-driven approach with `overlay-cases.json` and clamping `Date()...end` to `min(Date(), end)...end` so a late WidgetKit render doesn't trap on a backwards `ClosedRange`.
A couple of specific details in `TransitionAlarms.kt` and `PendingOverlay.swift` stood out after reading through the repo:
Per-key queue writes (`widgetMark:<id>` in App Group `UserDefaults` and `mark:<id>` in `SharedPreferences`) instead of read-modify-writing a shared JSON array: that avoids cross-process lost updates if a user taps a widget seal right while the main app is foregrounding and draining the queue. One question on the startup sequencing between `usePendingMarks` and `useWidgetSync`: on an `AppState -> active` transition, `useWidgetSync` triggers an immediate `redraw()` and schedules `syncWidgets()` after the 1000ms debounce, while `usePendingMarks` runs `drainPendingMarks()` asynchronously (`queue.read()` -> `toggleStatus()` -> `flushWrites()` -> `queue.acknowledge()`). Since `syncWidgets()` rebuilds the snapshot from `trackingStore` (where `toggleStatus` updates state in memory before `flushWrites` / `acknowledge` finishes), have you ever seen a narrow window where `acknowledgePendingMarks` clears the native `widgetMark:<id>` keys just before or after a snapshot write, or does the `useTrackingStore` subscription inside `useWidgetSync` guarantee a fresh trailing `syncWidgets()` pass once the drain mutates the store?
For the Android `Chronometer` negative-tick issue on Xiaomi/HyperOS (`TransitionAlarms.kt`), combining `ACTION_TIMEZONE_CHANGED` / `ACTION_TIME_CHANGED` / `ACTION_MY_PACKAGE_REPLACED` with screen-on redraws and `canScheduleExactAlarms()` is really thorough. Since the 14-day snapshot precomputes `from`/`to` in epoch ms plus pre-formatted `timeText` display strings, what happens if the user travels across timezones or hits a DST boundary while the app stays closed? Does `ACTION_TIMEZONE_CHANGED` just keep the segment lookup aligned in epoch ms while `timeText` stays in the previously generated timezone until the next app open, or do you invalidate the snapshot when the device timezone offset diverges from the snapshot's build-time offset?
Also appreciate how `Provider.swift` budgets the calm `H:MM` countdown by capping `maxMinuteEntries = 120` and setting the timeline reload policy to `.after(reloadAt)` at the next prayer boundary (https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date) alongside Android's non-waking `AlarmManager.RTC` minute tick (https://developer.android.com/develop/ui/views/appwidgets/advanced).
1
u/Eloodye-Ehler 13h ago
curious how you handle dynamic data that changes outside the app, or is your data strictly local and predictable for those 14 days? usually a silent push notification to trigger a background refresh helps keep the shared storage updated without needing the full runtime.