●RORK MAX — Rork Max can now build native Swift apps for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro●PUBLISH — Rork Max offers two-click App Store publishing with no Xcode required, cutting the friction of getting an app shipped●EXPO — The standard Rork is built on React Native (Expo), generating native iOS and Android apps from plain-English descriptions●PRICING — Rork is free to start, with paid plans beginning at $25/month, an accessible tier for solo developers●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz) as investment keeps flowing into AI app builders●REVIEW — In real use the keys are generated-code readability and maintainability, Expo-related constraints, and how easily billing, push, and ad SDKs slot in●RORK MAX — Rork Max can now build native Swift apps for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro●PUBLISH — Rork Max offers two-click App Store publishing with no Xcode required, cutting the friction of getting an app shipped●EXPO — The standard Rork is built on React Native (Expo), generating native iOS and Android apps from plain-English descriptions●PRICING — Rork is free to start, with paid plans beginning at $25/month, an accessible tier for solo developers●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz) as investment keeps flowing into AI app builders●REVIEW — In real use the keys are generated-code readability and maintainability, Expo-related constraints, and how easily billing, push, and ad SDKs slot in
Putting a Working Button in a Rork App's Widget — Implementing App Intents So a Tap Acts Without Opening the App
How to put a button in a Rork-generated Expo app's widget that changes state without launching the app. We wire App Intents and WidgetKit together through an App Group, all the way to reloadTimelines — including the two places I lost real time.
One morning I was looking at my habit-tracker widget and my hand just stopped. To tick a single item, I open the app, wait for a load screen, scroll to the row, then tap. Almost five seconds for one piece of one-tap data. If I could check it directly on the widget, those five seconds would become zero.
Since iOS 17, a button inside a widget can run work without launching the app. I knew the mechanism. But making it happen inside a Rork-generated, Expo-based app turned out to be far more involved than I expected — because the React Native world and the widget world run in different processes, in different languages.
This walks through getting the full round-trip working in a Rork app: tap a button in the widget, update state without opening the app, and have the widget redraw immediately. I'll leave the two places I got stuck explicit.
Why "add a button" doesn't work
Start with the structure. A home screen widget runs as an App Extension — a separate target from your app. There is no JavaScript engine inside that extension process. Not a single line of React Native runs there. That's exactly why asking Rork's chat to "add a button to my widget" gives you a button on an in-app screen, never one that completes its work on the home screen.
For a button inside a widget to act without launching the app, you have to provide and mesh together three parts yourself.
First, an App Intent — a unit of work you can hand to Button(intent:) that the system performs behind the scenes. Second, an App Group — the shared store that lets the app and the extension read and write the same data. Third, a timeline reload — the trigger that makes the widget redraw after the intent changes state.
Standard Rork (Expo-based) provides none of these automatically. Rork Max (which generates native Swift) can scaffold the whole WidgetKit extension for you, but if you're bolting this onto an app already shipped on Expo, the config-plugin route described here is the realistic one.
Get the App Group working first
Order matters. Before the button, make the app and the extension able to see the same data. If this isn't solid, you later get the worst kind of bug: the button fires, but nothing on screen changes, and the cause is hard to isolate.
Pick an App Group identifier (e.g. group.net.rorklab.habit). In your Apple Developer target settings, enable the same App Group on both the app and the extension. In Expo, add it to the entitlements in app.json.
To write into this shared store from React Native, you need a native module that talks to UserDefaults(suiteName:) — a library like expo-shared-defaults, or a thin bridge added through a config plugin. The minimal Swift bridge looks like this.
Now you have a two-way foundation: the widget extension can read a value the JS side wrote, and the JS side can read a value the extension wrote. Verify this round-trip with a single boolean first — it makes everything downstream far easier.
✦
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
✦Understand, with working Swift, how a button inside a widget runs without launching the app — the AppIntent / perform round-trip
✦A concrete path to connect a Rork Expo app and its extension target through an App Group: write-back, reloadTimelines, redraw
✦Take home the two traps that cost me two hours — taps doing nothing, and state rolling back — plus how to avoid each
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.
The button's substance is an App Intent. Put it on the extension (Swift) side. Here's a "toggle today's completion" intent for the habit tracker.
import AppIntentsimport WidgetKitstruct ToggleHabitIntent: AppIntent { static var title: LocalizedStringResource = "Toggle today's habit" @Parameter(title: "Habit ID") var habitID: String init() {} init(habitID: String) { self.habitID = habitID } func perform() async throws -> some IntentResult { let defaults = UserDefaults(suiteName: "group.net.rorklab.habit") let key = "done_\(habitID)_\(todayKey())" let current = defaults?.bool(forKey: key) ?? false defaults?.set(!current, forKey: key) WidgetCenter.shared.reloadTimelines(ofKind: "HabitWidget") return .result() } private func todayKey() -> String { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" return f.string(from: Date()) }}
When perform() runs, your app is not launched. So reaching for AdMob initialization or the RN bridge in here will fail. Inside perform(), treat the only safe operations as reading/writing the shared store and notifying WidgetCenter. Anything heavy belongs in a sync on the app's next launch.
That final reloadTimelines(ofKind:) is the third part — the reload. Skip it and you get changed state with a stale picture.
Draw the button in the widget view
In the widget's SwiftUI view, bind the intent to a button. Button(intent:) is the API built for exactly this.
HabitEntry carries the state the TimelineProvider read from the App Group. The provider reads the shared boolean, the view draws it, the button calls the intent, the intent rewrites the shared store and reloads. Close that loop and a check gets ticked without the app ever opening.
Trap #1: the button does nothing
This is where I first lost two hours. In the simulator I'd press the button and get nothing — no log, no state change.
The cause was where the App Intent lived. An App Intent invoked from Button(intent:) only runs if it's in the widget extension's target membership. Place the intent only in the app target while assuming the extension "imports" it, and the build succeeds while the runtime silently ignores it. In Xcode's File Inspector, confirm the intent's .swift file is checked for the extension target. If a config plugin generates your extension, verify after project.pbxproj generation that the plugin actually includes the intent file in the extension's sources.
One more thing: test on a device. Interactive widget behavior differs subtly between simulator and hardware, and reload timing in particular is more honest on a real device.
Trap #2: state rolls back right after the tap
The button worked. But tick a check, bring the app to the foreground, and the check is gone. A rollback.
The cause was a duplicated source of truth. The app treated its own persistent store (MMKV, in my case) as truth and wrote it out to the App Group on launch. Meanwhile the widget's intent writes the App Group directly. The instant the app returned to the foreground, it overwrote the App Group with its older value — as if to say "I'm the correct one" — erasing the widget's change.
The fix was to make writes one-directional. Treat the App Group as an inbox that receives widget-originated changes. On app resume, compare the App Group value against the app's own and merge by taking whichever has the newer timestamp. Just dropping the blind overwrite stopped the rollback.
// Merge on app resume (pseudocode)let widgetValue = sharedDefaults.bool(forKey: key)let widgetUpdatedAt = sharedDefaults.double(forKey: key + "_ts")if widgetUpdatedAt > localStore.updatedAt(for: key) { localStore.set(widgetValue, for: key) // adopt widget's value if newer}// Write the other direction only when local is newer
Keeping the source one-directional pays off well beyond widgets — it applies anywhere multiple processes touch the same state.
The real constraint: refresh budget
One operational note. An interactive widget's reloadTimelines requests an immediate redraw, but widgets as a whole live under an iOS-imposed refresh budget — designed to land around a few dozen automatic refreshes per day. User-initiated reloads from a button press are treated more generously, but if your intent's perform() hits an external API every time, the response lags and the tap loses its snappiness.
In my own setup, perform() does nothing but update the shared store and reload; server sync is batched on the app's next launch. Cast the widget as "a fast surface for actions at hand" and the app as "the place that absorbs heavy work in bulk," and you satisfy both the feel and the budget.
Once you're here, the next step
Start by closing the entire loop — button → shared store → reload → redraw — with a single boolean toggle. Once that minimal round-trip works even once, you can extend it with the same shape to lists of habits, quantity increments, and multiple widget sizes.
Bridging into territory Expo's stock features can't reach, with a config plugin and a small amount of Swift, is worth doing by hand once. After you've crossed that boundary yourself, the call between "this can stay in Rork (Expo)" and "from here it's faster to lean on Rork Max (native)" stops being a guess and becomes something you can feel. If you're stuck on the same round-trip, I hope this gives you a thread to pull.
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.