●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Why Your Rork iOS Widget Keeps Showing Stale Data — Rebuilding the App Group + WidgetKit Pipeline That Actually Refreshes
Your Rork-built iOS widget shows yesterday's data. Rebuild the App Group, shared UserDefaults, and timeline reload pipeline so parent and widget share state.
A few hours after I published the monthly update for one of my wallpaper apps, the home-screen widget kept showing last month's "daily wallpaper". Open the app, and the latest image was right there. Glance at the widget, and it was a full day behind. The first user message that night went something like, "is the widget broken?" and I was up far later than I planned, because telling a paying user "please reinstall" is the worst answer a small developer can give.
WidgetKit's documentation gives you a single sentence on this: "use an App Group to share data with the parent app." But once you actually graft a Widget Extension onto a Rork-generated React Native project, that one line hides three traps. The App Group is silently misconfigured, UserDefaults reads and writes don't agree on which keychain they're hitting, and the timeline reload runs at the wrong moment. This walkthrough fixes all three so that the parent app and the widget behave like one system in production, instead of two strangers passing each other on the home screen.
What "the widget is stuck on yesterday" actually means
Every iOS app, including a Widget Extension grafted onto a Rork project, runs in its own sandbox. Your widget looks like part of the parent app on the home screen, but it ships as a separate process with a separate bundle ID. iOS treats it as a separate citizen with its own data jar.
Which means: anything written to UserDefaults.standard in the parent app reads back as nil from inside the widget. That isn't a bug in your code, it's a contract between you and iOS. To cross the sandbox boundary, you have to ask Apple for an App Group, which gives both targets a shared keyed store and a shared file container. The first time I built a widget for an app, I didn't go through the App Group setup and spent half a day staring at a nil return value before realizing the storage I was writing to wasn't visible from the other side of the boundary.
There's a deeper consequence too. WidgetKit caches the last rendered timeline. If the parent app updates a value but never tells WidgetKit "the underlying data has changed", the widget will keep using its cached timeline until the cached entry's policy expires. So even when the App Group is wired up correctly, if you don't pair writes with explicit reload calls, the widget appears broken to the user — even though the data is fine in the shared container.
Issuing the App Group — don't reuse your bundle ID
The App Group is a separate identifier from the parent app's bundle ID, prefixed with group.. The convention is group.{reverse-domain}.{appname}.shared. The first mistake I see people make is reusing com.example.myapp directly as their App Group string. App Groups are a different namespace entirely — they need to be registered separately.
The setup goes:
Open the Apple Developer Portal → Identifiers → App Groups, and register a new one (e.g. group.com.example.myapp.shared).
On both the parent app's and the Widget Extension's identifiers, attach this App Group under Capabilities.
In Xcode → Signing & Capabilities for both targets, add the "App Groups" capability and tick the ID you just registered.
Confirm both targets' *.entitlements files now contain:
If the entitlement is missing on one of the two targets, you don't get a clean compile-time error. You get the worst possible runtime: writes appear to succeed (because you wrote into a different, target-local UserDefaults), reads return nil (because the other side is looking at the actual shared container, which is empty), and you waste hours wondering why your code doesn't work. After running an indie app business since 2014, I now check both entitlements files first whenever I add a new extension, before I even open Xcode's signing UI.
A small ritual I picked up: keep the App Group ID in a single Swift constant, named so it shows up in code search. That way when something is broken, you grep the codebase and immediately see all the call sites that depend on that ID, instead of hunting through Info.plist, entitlements files, and SwiftUI views one at a time.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Fold App Group, shared UserDefaults, and timeline reloads into one design that stops the widget from getting stuck on stale data
✦Implementation code for working with the system refresh budget, instant updates via App Intent, and deep-linking from widgetURL into Expo Router
✦The distribution-profile trap that passes TestFlight but breaks the App Store build, plus the exact pre-submission check
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Get a shared UserDefaults container working before anything else
Once the App Group is registered, share data through UserDefaults(suiteName:). Resist the urge to skip this minimal verification step — every later layer assumes this works.
// SharedStorage.swift — add to BOTH the parent app and Widget Extension targetsimport Foundationimport WidgetKitenum SharedStorage { // Both targets must reference the same App Group ID private static let suiteName = "group.com.example.myapp.shared" static var defaults: UserDefaults { // Returns nil if the App Group capability is missing on either target. guard let d = UserDefaults(suiteName: suiteName) else { assertionFailure("App Group \(suiteName) is not configured") return .standard } return d } // Parent app — write the latest wallpaper metadata static func saveWallpaper(id: String, title: String, date: Date) { let data: [String: Any] = [ "id": id, "title": title, "date": date.timeIntervalSince1970 ] defaults.set(data, forKey: "currentWallpaper") // Hand WidgetKit a hint that the timeline should be re-evaluated WidgetCenter.shared.reloadTimelines(ofKind: "DailyWallpaperWidget") } // Widget — read it back static func loadWallpaper() -> (id: String, title: String, date: Date)? { guard let d = defaults.dictionary(forKey: "currentWallpaper"), let id = d["id"] as? String, let title = d["title"] as? String, let ts = d["date"] as? TimeInterval else { return nil } return (id, title, Date(timeIntervalSince1970: ts)) }}
Add SharedStorage.swift to both targets — in the Xcode file inspector, tick the parent app and the Widget Extension. Sharing the same source file across both sides is the cheapest insurance against key-name drift, which is otherwise the most common silent bug. If you let the parent and the widget each carry their own copy of the storage layer, sooner or later one of them gets renamed without the other, and you spend an evening tracking down a typo.
I always validate this on a real device before going further. Run the parent app, write a value, then long-press the widget on the home screen and re-add it. If your value shows up, the App Group is wired correctly. If it doesn't, fix it now — not after you've layered the Expo bridge and the timeline policy on top, when the failure mode becomes much harder to isolate.
A concrete tip: while you're verifying, hardcode an obviously fake value like "test-wallpaper-2026-05-10" from the parent and look for that exact string in the widget. It removes any ambiguity around "is the right code path being hit" before you start trusting real data.
Bridging the data write from the JS (Rork) side
The Swift side is sorted. Now we need to push values from the JS world Rork operates in into the same shared container. This is the layer most React Native developers underestimate, because it looks like a one-line AsyncStorage.setItem problem and is actually a sandboxing problem.
Rork apps run on Expo Managed by default, so the cleanest path is to run expo prebuild, then add a small Expo Module that exposes a native bridge. If you're new to that workflow, Adding native modules to a Rork-generated app — the Expo Prebuild playbook walks through the prerequisites; after that, the snippets below should fall into place. AsyncStorage will not help you here — it writes only to a JS-side bundle directory that the widget cannot see.
// modules/shared-storage/ios/SharedStorageModule.swiftimport ExpoModulesCoreimport WidgetKitpublic class SharedStorageModule: Module { public func definition() -> ModuleDefinition { Name("SharedStorage") AsyncFunction("saveWallpaper") { (id: String, title: String, dateMs: Double) in // The JS side passes Date.now()-style ms; convert to seconds for Date. let date = Date(timeIntervalSince1970: dateMs / 1000.0) SharedStorage.saveWallpaper(id: id, title: title, date: date) } AsyncFunction("reloadWidgets") { () in // Explicit reload entry point for app launch / foreground events. WidgetCenter.shared.reloadAllTimelines() } }}
The JS side stays small and focused:
// app/services/shared-storage.tsimport { requireNativeModule } from "expo-modules-core";const NativeSharedStorage = requireNativeModule("SharedStorage");export async function publishCurrentWallpaper(wallpaper: { id: string; title: string; date: Date;}) { // Hand the latest metadata to the shared container. await NativeSharedStorage.saveWallpaper( wallpaper.id, wallpaper.title, wallpaper.date.getTime() );}export async function refreshWidgets() { // Call this from app launch / foreground transitions. await NativeSharedStorage.reloadWidgets();}// Expected behavior:// - publishCurrentWallpaper writes into the App Group; WidgetKit reads it// on the next timeline evaluation.// - refreshWidgets calls WidgetCenter.reloadAllTimelines(), which queues// a re-evaluation rather than instantly redrawing.
One important caveat: reloadAllTimelines() does not redraw the widget on the spot. WidgetKit batches reloads to save battery, so what you're really saying is "please consider this widget for re-evaluation soon." If you're testing and don't see an immediate refresh, that's normal — give it 5 to 30 seconds. I've watched newer iOS developers panic when the widget doesn't update in 200ms and start ripping out their code, when the underlying logic was correct all along.
If you want a tighter loop while iterating, you can target a specific widget kind with WidgetCenter.shared.reloadTimelines(ofKind: "DailyWallpaperWidget") — that scopes the reload to one widget instead of the whole family, and tends to come back slightly faster on a real device.
Designing when the timeline actually reloads
There are two reload triggers, used together:
Time-based: inside getTimeline, hand WidgetKit a "next evaluation" date.
Event-based: from the parent app, call reloadTimelines(ofKind:) whenever underlying data changes.
A daily wallpaper widget needs both. Time-based handles "midnight rolled over without the user opening the app." Event-based handles "the user just changed their favorite in the app." Skipping either one creates a gap where the widget stops feeling alive.
// DailyWallpaperWidget.swift — Widget Extensionstruct WallpaperProvider: TimelineProvider { func placeholder(in context: Context) -> WallpaperEntry { WallpaperEntry(date: Date(), title: "Today's Wallpaper", id: "placeholder") } func getSnapshot(in context: Context, completion: @escaping (WallpaperEntry) -> Void) { completion(loadEntry()) } func getTimeline(in context: Context, completion: @escaping (Timeline<WallpaperEntry>) -> Void) { let entry = loadEntry() // Schedule a re-evaluation at the next midnight. // Without this, the widget will not refresh on a date change. let nextMidnight = Calendar.current.nextDate( after: Date(), matching: DateComponents(hour: 0, minute: 0), matchingPolicy: .nextTime ) ?? Date().addingTimeInterval(60 * 60 * 24) let timeline = Timeline(entries: [entry], policy: .after(nextMidnight)) completion(timeline) } private func loadEntry() -> WallpaperEntry { if let w = SharedStorage.loadWallpaper() { return WallpaperEntry(date: w.date, title: w.title, id: w.id) } return WallpaperEntry(date: Date(), title: "—", id: "empty") }}
A common pitfall: shipping with policy: .atEnd and a single entry. .atEnd means "reload after the last entry's date passes," and with only one entry, that effectively never fires for a date-rollover use case. Pass .after(nextMidnight) explicitly so you control when the next evaluation happens. This single line is responsible for a surprising number of "the widget doesn't update at midnight" support emails.
For widgets that show high-frequency data — say, a podcast app's "now playing" indicator — you can't lean on time-based reloads alone. iOS will heavily throttle the timeline budget. In that case, lean even harder on event-based reloads triggered from the parent app, and keep the timeline window short with two or three entries spaced minutes apart. The combination is what makes the widget feel responsive without exceeding the system reload budget.
Before / After: the launch-time staleness problem
The original wallpaper widget I shipped had this flow:
Before (the broken behavior)
Parent app launches → fetches latest wallpaper from API → writes to AsyncStorage → widget remains stuck on yesterday's image.
"Refresh" inside the app does nothing visible to the widget.
Hours later, sometimes the widget catches up on its own, which made it look like an intermittent bug.
Two things were wrong. First, AsyncStorage writes only to the JS-side bundle directory, never the native App Group. Second, the parent app never called WidgetCenter.shared.reloadTimelines after updating data, so WidgetKit had no signal to re-evaluate. From the widget's perspective, nothing had changed, because nothing it could see had changed.
After (the fixed flow)
Parent app launches → fetch → publishCurrentWallpaper writes to the App Group → refreshWidgets queues a reload.
The widget catches up within seconds to tens of seconds.
// app/screens/home.tsximport { useEffect } from "react";import { fetchTodayWallpaper } from "../api/wallpaper";import { publishCurrentWallpaper, refreshWidgets } from "../services/shared-storage";export default function HomeScreen() { useEffect(() => { (async () => { const w = await fetchTodayWallpaper(); // 1) Write to the shared container await publishCurrentWallpaper(w); // 2) Ask WidgetKit to re-evaluate await refreshWidgets(); })(); }, []); // ...}
For long-lived apps I bake the reload into the writer, so a publishCurrentWallpaper call is always paired with a reload. Splitting them is fine while the codebase is small; pairing them is what stops "I wrote but forgot to reload" from sneaking back in once the app grows past one screen and one engineer.
If your app sees data updates from background tasks or push notifications too, repeat this pattern at every entry point. Anywhere the parent app could possibly know the truth has changed, you owe the widget a reload.
Move large data out of UserDefaults into the shared file system
UserDefaults is for settings and small metadata, not for blobs. The headroom on a suiteName UserDefaults is in the low megabytes, but performance starts to degrade as soon as you cross a few hundred kilobytes. My first wallpaper-widget prototype shoved a Base64-encoded image into UserDefaults, which made the home screen go blank for a couple of seconds every time iOS re-rendered the widget — exactly the kind of jank that makes a polished app feel cheap.
Switch to the shared file container for anything larger than text:
// SharedStorage helpers for the shared file containerextension SharedStorage { static var sharedContainerURL: URL? { FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: suiteName ) } static func saveWallpaperImage(data: Data, name: String) throws { guard let dir = sharedContainerURL else { return } let url = dir.appendingPathComponent(name) try data.write(to: url, options: .atomic) } static func loadWallpaperImageURL(name: String) -> URL? { sharedContainerURL?.appendingPathComponent(name) }}
In the widget, render with Image(uiImage: UIImage(contentsOfFile: url.path)). UserDefaults stores the filename ("which image to show"); the file system stores the bytes. Splitting metadata from blob payloads is the configuration that holds up under traffic, and it generalizes well — the same pattern works for thumbnails in a podcast widget, profile avatars in a social app widget, or even JSON document caches used by an offline-first reader.
A few practical guardrails when you go this route:
Use .atomic writes to avoid the widget reading a half-written file mid-update.
Name files deterministically (e.g. daily-wallpaper-{yyyy-MM-dd}.jpg) so you can clean up older entries on a schedule.
Keep the directory size capped — a daily image accumulates fast over a year, and the system won't volunteer to clean it for you.
What works on TestFlight but breaks in App Store review
One last trap I lost a release to: App Group capabilities don't always carry over from the Development provisioning profile to the App Store Distribution profile. I once shipped an update where the widget loaded perfectly in TestFlight, then collapsed to its placeholder state in production builds — for nearly a day before I noticed the spike in "widget broken" feedback.
To avoid the "TestFlight works, App Store build doesn't load the widget" flavor of bug:
In the Apple Developer Portal → Profiles, regenerate Distribution profiles for both the parent app and the Widget Extension.
Visually confirm both profiles list "App Groups" under Enabled Capabilities.
In Xcode → Settings → Accounts → "Download Manual Profiles" so the new profiles are picked up.
After archiving, open the .ipa (Show Package Contents → embedded.mobileprovision) and confirm com.apple.security.application-groups is present.
What kept those store releases stable wasn't picking the right framework — it was making this distribution-profile check a fixed ritual before every submission. The profile mismatch is invisible in your source code; it only shows up in the signed build. I learned to step through this verification by hand, one item at a time, after losing a release to it. It's unglamorous work, but skipping it is what turns a five-minute check into a day of post-launch firefighting.
If you ever look at a release checklist that feels too long, that's usually fine. Hardware and Apple's review system have their own constraints; they don't soften because your timeline is tight. Treat the verification step as part of the craft.
Make peace with the system refresh budget — spamming reload won't help
The intuition that calling reloadAllTimelines more often means faster updates does not hold here. WidgetKit hands each home-screen widget a loose refresh budget — roughly 40–70 reloads per day, varying with device state, battery, and how often the widget is actually viewed. Burn through that budget and the system defers your reload requests no matter how many you fire, which produces the "I just updated it and it's stale again" symptom.
In my wallpaper app I called refreshWidgets on every screen transition. It looked conscientious, but the more the user moved around inside the app, the more budget I wasted — and the update I actually cared about, the one at the stroke of midnight, never got its turn.
The design rules are simple:
Fire event-based reloads only when the shared data actually changed. If the value is identical, don't call reload.
Leave periodic refreshes to the getTimeline policy; don't nag the system with reload calls.
While debugging, use WidgetCenter.shared.getCurrentConfigurations to see how many widgets are actually placed, so you're not reloading a kind nobody installed.
// A small guard that reloads only when the value changedextension SharedStorage { static func publishIfChanged(id: String, title: String, date: Date) { let defaults = UserDefaults(suiteName: suiteName) let prevId = defaults?.string(forKey: "wallpaper.id") guard prevId != id else { return } // same wallpaper, do nothing saveWallpaper(id: id, title: title, date: date) WidgetCenter.shared.reloadTimelines(ofKind: "DailyWallpaperWidget") }}
Reaching for reloadTimelines(ofKind:) instead of reloadAllTimelines, scoped to the one kind you changed, is another quiet but effective way to avoid draining a limited budget.
Let people act straight from the widget — return an instant update with App Intent
From iOS 17 on, a button or toggle in a widget can run an AppIntent. People can "add to favorites" or move to the "next wallpaper" without ever opening the app, which lets you close the loop between updating shared data and refreshing the widget in one motion.
import AppIntentsimport WidgetKitstruct NextWallpaperIntent: AppIntent { static var title: LocalizedStringResource = "Next wallpaper" func perform() async throws -> some IntentResult { // advance the state in the shared container (e.g. bump the index) SharedStorage.advanceToNextWallpaper() // after perform returns, the system re-renders the widget for you return .result() }}
You place the button in SwiftUI as Button(intent:):
struct DailyWallpaperWidgetView: View { var entry: WallpaperEntry var body: some View { VStack { Text(entry.title) Button(intent: NextWallpaperIntent()) { Image(systemName: "arrow.right.circle") } } .containerBackground(.fill.tertiary, for: .widget) }}
The key point: if you update the shared container inside perform, the system re-evaluates the widget after the interaction without you calling reloadTimelines explicitly. The principle holds here too — the source of truth always lives in the App Group container. When the Rork (JS) side needs the same state, it re-reads the shared container on the next app launch and reflects it in the UI.
Open the right screen, not just the app, on tap
When someone taps the widget, opening the app's home screen is a missed opportunity; opening straight to the detail screen for the wallpaper the widget was showing is noticeably better. WidgetKit lets you attach a deep-link URL with widgetURL (whole widget) or Link (a sub-region).
// Widget Extension — carry the currently shown wallpaper ID in the URLDailyWallpaperWidgetView(entry: entry) .widgetURL(URL(string: "myapp://wallpaper/\(entry.id)"))
On the Expo / React Native side, receive that URL with expo-linking and route on it:
// near the top of app/_layout.tsximport * as Linking from "expo-linking";import { useEffect } from "react";import { router } from "expo-router";export function useWidgetDeepLink() { useEffect(() => { const handle = (url: string | null) => { if (!url) return; const { hostname, path } = Linking.parse(url); // myapp://wallpaper/<id> if (hostname === "wallpaper" && path) { router.push(`/wallpaper/${path}`); } }; // URL handed over at cold start Linking.getInitialURL().then(handle); // URL arriving while the app is already running (warm) const sub = Linking.addEventListener("url", ({ url }) => handle(url)); return () => sub.remove(); }, []);}
The thing to get right is handling both getInitialURL (cold start) and addEventListener (warm launch). Handle only one and you get a hard-to-reproduce bug where the detail screen fails to open only when the app was fully terminated before the tap. Keep the scheme (myapp://) identical to the scheme in app.json, and confirm it survived into Info.plist after prebuild.
The first thing to do tomorrow
If you're adding an iOS widget to a Rork-generated app, anchor the work on two decisions: the App Group ID naming convention, and the location of SharedStorage.swift. Register the App Group as its own identifier (not your bundle ID), and add the same Swift file to both targets. Lock those down first, and the JS bridge and timeline design layer on without rework.
Then run the smallest end-to-end loop: call saveWallpaper from the parent app, long-press the widget on a real device, and confirm the new value shows up. Once that single loop works, every later layer — the Expo Module wrapper, the timeline reload policy, the file-system blob storage, the distribution profile audit — extends from the same SharedStorage foundation. Skipping that loop is what creates the "everything looks correct but nothing updates" debugging spiral.
Widgets are one of the corners Rork can't fully cover on its own, but learning to bridge into them quietly expands what an indie developer can ship. I hope your app's widget feels alive and current after your next build.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.