●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Rork Max × Apple TipKit — Designing In-App Guidance That Cuts Onboarding Drop-Off
Wire Apple's TipKit framework into a Rork Max app via Expo Modules to ship contextual in-app guidance that meaningfully reduces onboarding drop-off — with eligibility rules, localization, CloudKit sync, and analytics covered end to end.
The third app I shipped was the first time I tried a five-screen onboarding flow. The drop-off data was brutal: roughly half of new users were gone by screen three, and fewer than 20% reached the end. By trying to teach everything up front, I taught nothing.
What I switched to after that failure — and have stuck with ever since — is surfacing guidance the moment a user is ready for it, using Apple's TipKit framework, integrated into a Rork Max app at production quality. Up front: TipKit isn't just a tooltip library. Treat it as a design language for eligibility, frequency, sync, and measurement, and your retention numbers start to look different.
Why TipKit Now — And Why Onboarding Screens Aren't Enough
TipKit, introduced in iOS 17, is Apple's official framework for surfacing short, contextual hints exactly when a user gets near a feature. The fundamental flaw of traditional onboarding screens is that they explain things before any context exists. No matter how well-crafted the copy is, it bounces off.
TipKit inverts the order. "The filter explanation only matters once a user has seen too many search results." "The widget tip only matters the second they hit the home screen." You inject a one-line nudge right next to the moment that gives it meaning. In my own data, switching from a five-screen onboarding flow to a TipKit-driven approach pushed feature-level activation up by roughly 1.7x for the features I instrumented.
The other practical win is that you stop hand-rolling @AppStorage flags. Rules like "show at most three times a week," "never show again after the user taps Got It," or "don't show if another tip is currently visible" are all delegated to TipKit's rules engine. Your React Native code in Rork Max stays clean while the display logic lives in native Swift, where it belongs.
TipKit's Mental Model in Three Minutes
The core type is the Tip protocol. Each tip is an independent Swift struct that declares its title, message, image, actions, and the rules that govern when it can appear. There are two presentation modes: a popover (callout bubble) and an inline view embedded in your layout.
import TipKitstruct AddToCollectionTip: Tip { var title: Text { Text("Add to your collection") } var message: Text? { Text("Long-press any image to save it to a collection in one tap.") } var image: Image? { Image(systemName: "heart.fill") } var actions: [Action] { [ Action(id: "learn-more", title: "Learn more"), Action(id: "got-it", title: "Got it") ] }}
The critical detail: a Tip doesn't decide whether it's visible. The TipKit runtime evaluates rules and donated events to decide for you. Calling Tips.configure(...) once at launch sets up the data store and frequency settings, and from then on you only think in terms of rules and events.
✦
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
✦If you've been losing users in your onboarding flow, you'll walk away with a TipKit-based design you can ship today that surfaces guidance only when it's actually useful.
✦You'll see two bridging designs side by side — the SwiftUI popover route and using shouldDisplayUpdates so React Native draws the tip — plus what each one costs you in display accounting.
✦You'll get judgment criteria for running tips at scale: localization across four-plus languages, CloudKit sync trade-offs, and the three metrics that actually tell you whether a tip is helping or hurting.
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.
End-to-End Architecture: TipKit From Inside Rork Max
Rork Max sits on React Native and Expo, so Swift-only APIs like TipKit aren't directly accessible. The architecture I converged on uses an Expo Modules bridge so that the JS side only has to know how to register tips, donate events, and receive action callbacks. Everything else — rule evaluation, persistence, sync — stays in Swift. This is deliberate: the more state you leak into JS, the harder maintenance gets six months later.
The flow at runtime: at app launch, Swift runs Tips.configure(...), registers tip definitions, and attaches them as SwiftUI view modifiers to the relevant native subviews. From the Rork Max side, when something meaningful happens — "search opened," "third search executed" — JS calls into the Expo Module to fire event.donate(). TipKit then decides, based on its registered rules, whether and when to display.
// ios/RorkTipKitModule/RorkTipKitModule.swiftimport ExpoModulesCoreimport TipKit@available(iOS 17.0, *)public class RorkTipKitModule: Module { public func definition() -> ModuleDefinition { Name("RorkTipKit") OnCreate { do { try Tips.configure([ .displayFrequency(.immediate), .datastoreLocation(.applicationDefault) ]) } catch { // Don't crash the app on a configuration failure; // log to your crash reporter (Crashlytics, Sentry, etc.) NSLog("[RorkTipKit] configure failed: \(error.localizedDescription)") } } AsyncFunction("donateEvent") { (eventId: String) -> Void in await TipEventCenter.shared.donate(eventId) } AsyncFunction("invalidateTip") { (tipId: String) -> Void in await TipEventCenter.shared.invalidate(tipId) } Events("onTipAction") }}
Declaring Events("onTipAction") makes this an event emitter the JS side can subscribe to. When a tip's action button is tapped, you sendEvent("onTipAction", payload) and React Native picks it up. One piece of advice from experience: even if you only have one action right now, model the payload as an array of actions from day one. You'll add more, and refactoring the bridge later is no fun.
Step 1: Get the Minimum Path Running
Build it up in stages. First, add Expo Modules to your Rork Max project and drop the Swift module into ios/RorkTipKitModule/. Then write the thin TypeScript wrapper.
// modules/rork-tipkit/index.tsimport { requireOptionalNativeModule } from "expo-modules-core";type TipActionPayload = { tipId: string; actionId: string;};// Since SDK 52 the module's JS object is a NativeModule that already// extends EventEmitter. The old new EventEmitter(module) form is gone.const RorkTipKit = requireOptionalNativeModule("RorkTipKit");export const tipKit = { /** * Records an event that may trigger a registered tip to display. * Safe to call repeatedly — TipKit handles frequency control. */ async donate(eventId: string): Promise<void> { if (!RorkTipKit) return; // No-op on iOS < 17 and Android try { await RorkTipKit.donateEvent(eventId); } catch (e) { console.warn("[tipKit.donate] failed", e); } }, /** * Marks a tip as dismissed. * Always call this when the user explicitly closes a tip. */ async invalidate(tipId: string): Promise<void> { if (!RorkTipKit) return; try { await RorkTipKit.invalidateTip(tipId); } catch (e) { console.warn("[tipKit.invalidate] failed", e); } }, /** * Subscribes to action button taps. * Returns an unsubscribe function. */ onAction(listener: (payload: TipActionPayload) => void): () => void { if (!RorkTipKit) return () => {}; const subscription = RorkTipKit.addListener("onTipAction", listener); return () => subscription.remove(); }};
This is one of the spots Expo SDK 52 quietly changed. Before it, you wrapped the module in new EventEmitter(nativeModule); now the module object carries addListener itself. Copy the older pattern from a blog post and you get a subscription that never fires — no error, no warning, just silence. If you're migrating, check this first.
The reason for requireOptionalNativeModule is that without it your Android build (or an iOS 16 device) will crash on launch with "Cannot find native module." I learned this the hard way: my first TestFlight invite to an Android tester got a screenshot of the app refusing to open. Any Swift-only API needs to be wrapped optionally before it ships.
The expected behavior: after the search screen has been opened a few times, a popover saying "Try filtering your results" appears next to the filter icon — automatically — based on the rules you'll write next.
Step 2: Eligibility Rules — Showing Tips at the Right Moment
If you only donate and skip rules, the tip fires every time. That's the fastest way to make users hate your app. This is where TipKit either earns its keep or doesn't.
// ios/RorkTipKitModule/Tips/FilterTip.swiftimport TipKit@available(iOS 17.0, *)struct FilterTip: Tip { static let searchOpenedEvent = Event(id: "search.opened") static let filterUsedEvent = Event(id: "filter.used") var id: String { "filter.tip" } var title: Text { Text("You can narrow this down") } var message: Text? { Text("When you have too many results, the filter at the top can cut them quickly.") } var image: Image? { Image(systemName: "line.3.horizontal.decrease.circle") } var rules: [Rule] { // The user has opened search at least 3 times #Rule(Self.searchOpenedEvent) { $0.donations.count >= 3 } // ...and has never used the filter #Rule(Self.filterUsedEvent) { $0.donations.count == 0 } // ...and has been active in the last 24 hours // (don't ambush a returning user on their first session back) #Rule(Self.searchOpenedEvent) { $0.donations.donatedWithin(.days(1)).count >= 1 } }}
Two things to internalize: rules are AND-evaluated, and donations are cumulative across the app's lifetime. My first painful mistake was relying only on donations.count >= 3. A user who'd opened the app three times six months ago would, on their first session back, immediately get hit with a tip. Adding even one time-window rule with donatedWithin(.days(1)) made the UX dramatically less hostile.
Step 3: Popover Display and SwiftUI Wiring
With definitions and events in place, the last piece is presentation. In SwiftUI, you simply attach .popoverTip(_:) or .tipBackground(_:) to the view that owns the feature.
Most of your Rork Max app is React Native, but the host view of the tip — the filter icon, the tab bar item — sometimes lives in Swift. That happens when you have a custom tab bar in SwiftUI, a custom native stack header, or any view you've written natively for performance reasons.
// Inside a SwiftUI custom tab barimport SwiftUIimport TipKit@available(iOS 17.0, *)struct FilterButton: View { private let tip = FilterTip() var body: some View { Button(action: openFilter) { Image(systemName: "line.3.horizontal.decrease.circle") .font(.title2) } .popoverTip(tip, arrowEdge: .top) { action in // An action button was tapped. // Forward it to the parent module so JS can hear about it. RorkTipKitModule.dispatch( tipId: tip.id, actionId: action.id ) tip.invalidate(reason: .actionPerformed) } } private func openFilter() { // Donating "filter.used" means the user has tried the filter, // which other tips can use as a precondition. Task { await TipEventCenter.shared.donate(FilterTip.filterUsedEvent.id) } }}
If you need a popover anchored to a pure React Native view (a FlatList header icon, say), you'll have to grab the UIView reference and host the popover via popoverPresentationController yourself. My recommendation: don't fight it. Write the small "tip target" view in SwiftUI, wrap it in a SwiftUIRootView, and mount that from React Native. The codebase looks split, but each tip clocks in at around 30 lines of Swift, and maintenance ends up easier than going the all-React-Native route.
A Third Option: Let React Native Draw, and Use shouldDisplayUpdates as the Bridge
I said above that anchoring a popover to a React Native view means writing a small SwiftUI view. In an app that's almost entirely React Native, dragging a shard of SwiftUI in for a single tip doesn't always pay for itself.
There's another way in. Every Tip instance exposes shouldDisplayUpdates, an AsyncStream that emits whenever the answer to "may this tip be shown right now?" changes. You can subscribe to it without ever touching TipView or popoverTip. In other words, you can use TipKit purely as the rule engine and leave the drawing to React Native.
// ios/RorkTipKitModule/RorkTipKitModule+Observe.swiftimport ExpoModulesCoreimport TipKit@available(iOS 17.0, *)extension RorkTipKitModule { /// Streams eligibility changes for a tip out to JS. /// Hold the returned Task and cancel it when the module tears down. func observe<T: Tip>(_ tip: T, tipId: String) -> Task<Void, Never> { Task { @MainActor [weak self] in for await shouldDisplay in tip.shouldDisplayUpdates { self?.sendEvent("onTipDisplayChange", [ "tipId": tipId, "shouldDisplay": shouldDisplay ]) } } }}
On the JS side you render your own banner or tooltip for whichever tipId comes through:
There's a real cost to this, and it's worth naming before you commit. When you draw the tip yourself, TipKit never records that it was displayed. The display count and last-shown date are written by TipView and popoverTip, so rolling your own means displayFrequency and max-display budgets stop applying. You can confirm it in Xcode's Tips inspector: the display count sits at zero no matter how many times your banner appears.
My answer is to move frequency into the rules instead of relying on the runtime. A condition like "not if we showed this in the last seven days" is expressible in #Rule, and writing it out explicitly ends up clearer than trusting a budget you can no longer see. Dismissal is on you too — call invalidate(reason:) from your own close handler.
popoverTip / TipView
shouldDisplayUpdates + your own view
Appearance
Tracks the system style automatically
Matches your app's design
Display accounting
Recorded by TipKit
Not recorded — express frequency in rules
SwiftUI required?
Yes, the target view must live in Swift
No — only the eligibility check is native
Best for
Native tab bars and headers
Apps that are React Native nearly everywhere
Eligibility belongs to TipKit; presentation belongs to us. Deciding that line up front spares you the worst branch of all: rewriting every screen in SwiftUI six months in.
Localization — The Realistic Workflow for Multi-Language Apps
The Rork Lab apps I run target four languages: Japanese, English, Simplified Chinese, and Korean. TipKit pulls strings from Localizable.strings automatically when you pass localized keys to Text. But the operational reality is that all our copy lives in messages/{locale}.json files for the React Native side, and now suddenly the tip strings need to live somewhere else too.
The solution that has held up in CI: a build-time script that extracts the tipkit.* namespace from each locale JSON and writes it into the matching .lproj/Tips.strings file. Translators only ever touch JSON; nobody has to edit Swift code.
Setting process.exitCode = 1 (rather than throwing) lets the loop finish all locales while still failing the CI run if any one of them broke. Silent failures are the nightmare scenario here, so the slight verbosity is worth it.
CloudKit Sync — When "Already Seen" Crosses Devices
If a user has both an iPhone and an iPad, seeing a tip on one and getting it again on the other is the kind of detail that erodes trust. TipKit handles this for you — with one condition that costs me a TestFlight round to learn.
TipKit's CloudKit sync requires iOS 18 or later. The framework itself shipped in iOS 17; syncing was added a year on. Put the sync option inside a block marked @available(iOS 17.0, *) and Tips.configure fails outright on an iOS 17 device — no tips at all, not just no sync. I shipped exactly that and only found out when a tester wrote in to say the iPad never showed anything.
// ios/RorkTipKitModule/TipsBootstrap.swiftimport TipKit@available(iOS 17.0, *)enum TipsBootstrap { static func configure() throws { var options: [Tips.ConfigurationOption] = [ .displayFrequency(.immediate), .datastoreLocation(.applicationDefault) ] // Sync only exists from iOS 18 on. if #available(iOS 18.0, *) { options.append(.cloudKitContainer(.automatic)) } try Tips.configure(options) }}
.automatic picks the first iCloud container in your entitlements whose identifier ends in .tips — the naming convention is load-bearing here, which is unusual for an Apple API. To be explicit instead, pass .named("iCloud.net.rorklab.example.tips").
The Xcode side has to match. Add the iCloud capability with CloudKit checked, create a container identifier ending in .tips, then add Background Modes and enable Remote notifications so sync can progress while the app is backgrounded.
Requirement
What to set
What breaks if you don't
Minimum OS
iOS 18+
Tips.configure throws on iOS 17 and no tip ever shows
Container identifier
Must end in .tips
.automatic finds nothing to sync with
Background Modes
Remote notifications on
Sync only advances while the app is in the foreground
Alongside Core Data / SwiftData
Separate containers, minding order
Your persistent store grabs the tips container
That last row is the one that hides best. NSPersistentCloudKitContainer defaults to the first iCloud container identifier in your entitlements. Add the tips container above your app's own and your user data starts reaching for the wrong container. Either list the app identifier first, or set it explicitly before loading the store:
A practical caveat from running this in production: sync is convenient, but you can't lean on it. It stalls when the user is offline, signed out of iCloud, or out of storage. I design tips assuming sync is best-effort and never load-bearing — if a tip is meant to appear three times anyway, seeing it twice on each device because sync lagged isn't a failure.
Sync is there to improve the experience, not to hold the correctness. Get that backwards and your app degrades first for the users with the worst connections.
A/B Testing Tips Without Breaking Your Rule Engine
The trap most teams fall into when they want to A/B test tip copy is to fork the entire Tip struct, end up with FilterTipA and FilterTipB, and then have two parallel rule trees that drift over time. After running this for a quarter on three apps, I switched to keeping a single Tip definition and varying only the copy at render time, with the variant decision made server-side and cached locally.
@available(iOS 17.0, *)struct FilterTip: Tip { static var copyVariant: CopyVariant = .control var id: String { "filter.tip.\(Self.copyVariant.rawValue)" } var title: Text { Text(Self.copyVariant.titleKey) } var message: Text? { Text(Self.copyVariant.messageKey) } var image: Image? { Image(systemName: "line.3.horizontal.decrease.circle") } var rules: [Rule] { // Identical rules across variants. The variant only changes copy. #Rule(Self.searchOpenedEvent) { $0.donations.count >= 3 } #Rule(Self.filterUsedEvent) { $0.donations.count == 0 } }}enum CopyVariant: String, CaseIterable { case control, friendly, urgent var titleKey: LocalizedStringResource { "tipkit.filter.title.\(rawValue)" } var messageKey: LocalizedStringResource { "tipkit.filter.message.\(rawValue)" }}
Putting the variant suffix into id is what makes per-variant impression and dismissal counting possible inside TipKit's data store. Without that, all variants share a single display count and dismissed state, and you can't tell which copy actually moved the needle. The variant assignment itself I keep on the JS side, fed from a Cloudflare Workers KV-backed feature flag service, so I can shift traffic without an app update.
Event donations stay shared across variants, though — they're keyed by event id, with no link back to any particular tip. That's not a limitation to engineer around; it's the behaviour you want. "Opened search three times" has to mean the same thing in every arm, or the arms aren't comparable. Separate the tip records, share the events, and the numbers still read cleanly months later.
One word of caution: don't run more than two variants at a time per tip. The eligibility rules already make sample sizes small for indie-scale apps. With three or four variants, you'll wait months for any signal at all.
Production-Grade Bridge: Adding Action Forwarding
The minimal bridge earlier doesn't yet wire action callbacks back to JS. Here's the production version of the dispatch path. It runs on the main actor, packages the payload in a stable shape, and never crashes if the JS subscriber is gone.
// ios/RorkTipKitModule/RorkTipKitModule+Dispatch.swiftimport ExpoModulesCore@available(iOS 17.0, *)extension RorkTipKitModule { static weak var sharedInstance: RorkTipKitModule? static func dispatch(tipId: String, actionId: String) { // weak ref guards against the module being torn down during a reload guard let instance = sharedInstance else { return } Task { @MainActor in instance.sendEvent("onTipAction", [ "tipId": tipId, "actionId": actionId ]) } }}
I keep sharedInstance as a weak reference so the bridge survives debug-time React Native fast refreshes without leaking. On the JS side, listeners always come and go cleanly because the wrapper returns an unsubscribe function — but the native side has to be ready for the JS subscriber to disappear mid-flight. This is the kind of code that causes intermittent QA reports six months later if you don't get it right up front.
When to Reach for Inline Tips Instead of Popovers
The popover form gets most of the attention in TipKit examples, but the inline form is genuinely under-used and often the better choice. An inline tip is a view you embed in your layout — typically a banner near the top of a screen or a card inserted into a list. It doesn't interrupt; it sits in the user's flow and waits.
I now reach for inline tips whenever the guidance is about the content a user is currently looking at, rather than a specific button. "These results look thin — try broadening your search terms" reads naturally as a banner above an empty results list. The same idea as a popover floating over the search box would feel intrusive.
Mechanically, inline tips are dead simple:
// In a SwiftUI listif let tip = ResultsHelpTip.current { TipView(tip) .padding(.horizontal)}
The trade-off is that you're committing the layout cost — the tip occupies space whether or not it's a runtime hit. So inline tips work best when the layout already has room for an occasional banner and the message is about the content, not a missed action. For features behind a button, the popover is still the right tool.
A pattern I use: each screen has a single dedicated "tip slot" near the top, and at most one inline tip can occupy it at a time. The slot is a SwiftUI Group that asks each tip in priority order whether it wants to display, and renders the first one that does. This keeps the layout stable, prevents tip pile-ups, and gives me a single place to instrument impressions.
Common Pitfalls and How to Get Out of Them
The traps you're going to hit, in roughly the order I hit them:
Tips.configure runs before the App Delegate is ready. Expo Modules' OnCreate runs early in the app lifecycle. If your DI container, analytics SDK, or another initializer hasn't finished, you can crash trying to write to the data store. Wrap the call in Task { ... } so it dispatches asynchronously on the main actor.
A tip never appears. Almost always, this means at least one rule never evaluates true. Open Xcode's Tips inspector (Window menu) — it shows you the current state of every registered tip's rules. I lost half a day before learning this existed.
While you're in there, learn the reset helpers: Tips.resetDatastore(), Tips.showAllTipsForTesting(), and Tips.hideAllTipsForTesting(). All three have to run before Tips.configure(). Call them afterwards and nothing happens — which looks exactly like "the reset didn't work." Since OnCreate is also where you call configure, keep the ordering in one place:
OnCreate { do { #if DEBUG // Must come before configure, or it silently does nothing. if ProcessInfo.processInfo.arguments.contains("-ResetTips") { try Tips.resetDatastore() } #endif try TipsBootstrap.configure() } catch { NSLog("[RorkTipKit] configure failed: \(error.localizedDescription)") }}
Leave resetDatastore() outside the #if DEBUG and every launch wipes every user's tip history. Treat the guard as part of the call, not an optional extra.
One tip's events leaking into another's rules. Look at what TipKit persists and you find something easy to miss: events aren't attached to tips at all. They're keyed purely by the Event id, and donations for the same id are shared by every tip that references it. Declare Event(id: "opened") separately in two tips and opening screen A starts satisfying the rule for screen B's tip. Collect event declarations in one namespace so the ids can't drift into each other:
enum TipEvents { static let searchOpened = Tips.Event(id: "search.opened") static let filterUsed = Tips.Event(id: "filter.used") static let collectionOpened = Tips.Event(id: "collection.opened")}
Related: if you don't declare id on the tip itself, the default is the type name. Rename the struct during a refactor and TipKit sees a brand-new tip — so tips users already dismissed come back. Writing var id: String { "filter.tip" } explicitly closes that door.
Crashing after putting a struct in @Parameter.@Parameter accepts any Encodable type, but reaching into a struct's properties from a rule predicate crashes at runtime — #Rule(Self.$data) { $0.count > 3 } being the shape to avoid. Keep parameters to Bool, Int, and String, and express anything compound through event donation counts instead.
Forgetting to call invalidate(reason:). When a user taps an action or hits the close button, you must explicitly invalidate the tip, or TipKit will think it was never dismissed. From the user's perspective: "I closed it, why is it back?" Make invalidation a non-negotiable part of the popover callback.
Tests are hard to write. TipKit's internal state lives in a data store that's awkward to control from XCTest. My approach is to extract the predicate inside #Rule { ... } into a plain function and unit-test that function. The TipKit machinery itself I test via UI tests on a clean simulator install.
Android explodes despite "no-op." If you forget requireOptionalNativeModule, the Android build will fail at runtime with "Cannot find native module." Any cross-platform-aware module wrapper has to be optional + early-return on every method.
Tips appearing on the wrong screen. A subtle one: if you donate search.opened from a screen that mounts behind a modal, the popover can attach to a screen the user can't currently see. The user then dismisses the modal and walks away wondering why the filter button is glowing. Donate from the screen that owns the affordance, not from any screen that happens to mount the data.
Race conditions during deep links. When a deep link launches the app directly into a feature, your OnCreate may not have finished Tips.configure before your screen tries to render its tip. Defend against it with your own ready flag — have TipsBootstrap.configure() set an @MainActor boolean on success and gate the modifier on that. A missing tip is fine; a crash is not.
Measurement — Knowing Whether a Tip Actually Helped
A tip that fired but didn't change behavior is worse than no tip — you've added cognitive load for zero benefit. In my apps, the onAction listener emits a custom event to GA4 with the tip ID and action ID broken out as separate dimensions.
// expo-firebase-analytics was removed in SDK 48.// React Native Firebase is the supported path now.import analytics from "@react-native-firebase/analytics";tipKit.onAction(async ({ tipId, actionId }) => { await analytics().logEvent("tip_action", { tip_id: tipId, action_id: actionId // Track "feature usage within N seconds of impression" as its own event });});
If you're inheriting an older codebase, grep package.json for expo-firebase-analytics before anything else. The package stopped existing in SDK 48, so if the name is still there, that measurement isn't running. Believing you're judging tips on data while nothing is arriving is the most expensive mistake I made on this feature.
The three metrics worth watching: feature-usage rate within 24 hours of a tip impression, median time-to-first-use after the tip fires, and the close-rate of the app immediately after a tip appears. The third one is the canary — a tip with a high "user closed the app right after seeing it" rate is actively harming the experience and needs the copy or the timing fixed today, not next sprint.
For what it's worth, about a third of the tips I shipped initially turned out to be pure friction. The discipline TipKit forces is staring at "this tip I built actually made things worse" without flinching, and pulling it.
Designing Tips That Read as Helpful, Not Pushy
Copy quality is half the battle, and it's the half indie developers most often skip. Three rules I've internalized after dozens of iterations:
First, never describe the feature. The user already sees the button. Describe the outcome they'll get. "Find what you want faster" beats "Use the filter to narrow results" every time, because it speaks to their problem, not your UI.
Second, avoid the imperative voice. "Try the filter to see fewer results" sounds like a polite suggestion. "Tap the filter to narrow results" sounds like a manual. Tips compete for attention with everything else on screen; sounding like a friend rather than a manual buys you the half-second of curiosity that gets the tap.
Third, lead with the trigger condition in your own head. If you can't articulate "this user just did X, so they're now ready to hear Y," the tip probably shouldn't exist. Tips that exist for the developer's convenience, not the user's, are the ones that drive up close-rate.
I rewrite every tip three times before shipping it, and almost every revision is shorter than the previous one. The version that ships is usually 30-50% shorter than the first draft.
Ship-Day Checklist
Before you cut a build with a new tip, run through this list. It's the one I keep pinned in my notes app for every tip release.
The tip's id is unique across the app and follows your naming convention (feature.purpose).
All copy strings exist in every locale your app supports — no fall-throughs to English.
At least one rule constrains time (donatedWithin(.days(N))), not just total counts.
You've manually verified in the Tips inspector that the rules evaluate correctly for a fresh install and for a user who already met the conditions.
invalidate(reason:) is called on every action button and on the close button.
The action handler in JS has a fallback for unknown actionId values — variants you may add later.
You've decided whether the tip is local-only or CloudKit-synced, and documented why.
An analytics event fires on impression and on each action, with stable parameter names.
The tip's removal path is documented (when do you stop showing this and why).
Skipping any one of these is a tax you'll pay in support tickets within the month.
Pick one feature in your app — the single feature you most wish people would use — and ship one TipKit tip for it. A safe starting condition: "user has launched the app three times, has not used this feature, was active in the last 24 hours." Run it for a week and look at one number: did usage of that feature move? If yes, you've found a tool that works for your app. If no, the question isn't TipKit, it's the feature.
Tips work best when they're scarce. If it feels like you've added too few, you've probably added the right number.
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.