●BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the output●NATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other builders●PLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screen●COMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to end●PRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that back●DEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify●BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the output●NATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other builders●PLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screen●COMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to end●PRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that back●DEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify
When an Expo UI drop-in swap actually removes a dependency
Expo UI went stable in SDK 56 with drop-in replacements for eight community packages. Swapping one import does not always shrink your dependency list. Here is how to decide which swaps actually pay off, straight from the dependency graph.
I expected to delete one line from package.json. The line stayed.
Expo UI became stable in SDK 56. The pickers, sliders, and sheets that used to come from community packages are now backed by real SwiftUI and Jetpack Compose views, and eight of those packages ship drop-in replacements. The official wording is that most migrations are a one-line import swap.
The wallpaper app I run has a screen that pages through images horizontally. Because it handles both paging and jump-to-page, seeing react-native-pager-view on the replacement list felt like an easy win: swap the import, drop the dependency.
The dependency did not drop. The reason lived somewhere else entirely — in another package that requires it. And it was declared under peerDependencies, which means staring at package.json would never have shown it to me.
Before deciding whether to migrate, I needed a mechanical answer to a smaller question: does anything actually get removed?
The replacement table is not a removal table
These are the eight pairs that shipped in SDK 56. The mapping itself is public and stable.
Community package
Drop-in replacement
@react-native-community/datetimepicker
@expo/ui/community/datetime-picker
@react-native-community/slider
@expo/ui/community/slider
react-native-pager-view
@expo/ui/community/pager-view
@react-native-picker/picker
@expo/ui/community/picker
@react-native-segmented-control/segmented-control
@expo/ui/community/segmented-control
@react-native-masked-view/masked-view
@expo/ui/community/masked-view
@react-native-menu/menu
@expo/ui/community/menu
@gorhom/bottom-sheet
@expo/ui/community/bottom-sheet
This table answers one question: does a replacement exist? It says nothing about whether swapping to it shrinks your dependency list. That second question has a different answer in every project.
If your motivation is "I want this screen to look like the OS," the first question is enough. If your motivation is "I want fewer native dependencies so that fewer things break on every SDK bump," you need the second one answered before you estimate the work. Mine was the second.
peerDependencies do not show up in a package.json grep
A peer dependency says "I will not install this, but I assume it is there." If you use React Navigation's material top tabs, react-native-tab-view is in your tree, so react-native-pager-view stays. You could convert every one of your own screens to Expo UI and still remove exactly zero npm packages.
That was the trap. Whether a swap removes anything is decided not by the package you are replacing, but by whatever else declares it as a peer.
✦
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
✦You will be able to tell, with a single command, which of the eight Expo UI replacements actually remove a dependency from your specific project
✦You will catch the dependencies that survive the swap through peerDependencies before you spend an afternoon on the migration
✦If you share components across several apps, you will be able to pick which app and which screen to start with based on the dependency graph rather than a guess
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.
Reverse-resolving peers by hand does not scale. This script walks the installed tree and reports, for each of the eight candidates, who requires it and what would be orphaned if it went away.
Save it as scripts/expo-ui-dropin-audit.mjs and run it from the project root.
#!/usr/bin/env node// Decide whether an Expo UI drop-in swap actually removes a dependency.import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';import { join } from 'node:path';const REPLACEABLE = { '@react-native-community/datetimepicker': '@expo/ui/community/datetime-picker', '@react-native-community/slider': '@expo/ui/community/slider', 'react-native-pager-view': '@expo/ui/community/pager-view', '@react-native-picker/picker': '@expo/ui/community/picker', '@react-native-segmented-control/segmented-control': '@expo/ui/community/segmented-control', '@react-native-masked-view/masked-view': '@expo/ui/community/masked-view', '@react-native-menu/menu': '@expo/ui/community/menu', '@gorhom/bottom-sheet': '@expo/ui/community/bottom-sheet',};const root = process.argv[2] ?? process.cwd();const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };const rootPkg = readJson(join(root, 'package.json'));if (!rootPkg) { console.error(`No package.json at ${root}`); process.exit(1); }const directDeps = { ...(rootPkg.dependencies ?? {}) };// Read every installed package manifest, one level deep, scopes included.function collectInstalled(nmDir) { const out = new Map(); if (!existsSync(nmDir)) return out; for (const entry of readdirSync(nmDir)) { if (entry.startsWith('.')) continue; const full = join(nmDir, entry); if (!statSync(full).isDirectory()) continue; if (entry.startsWith('@')) { for (const sub of readdirSync(full)) { const pkg = readJson(join(full, sub, 'package.json')); if (pkg?.name) out.set(pkg.name, pkg); } } else { const pkg = readJson(join(full, 'package.json')); if (pkg?.name) out.set(pkg.name, pkg); } } return out;}const installed = collectInstalled(join(root, 'node_modules'));// Reverse index: who needs this package, split into runtime and peer.const requiredBy = new Map();const peeredBy = new Map();const push = (map, key, value) => map.set(key, [...(map.get(key) ?? []), value]);for (const [name, pkg] of installed) { for (const dep of Object.keys(pkg.dependencies ?? {})) push(requiredBy, dep, name); for (const dep of Object.keys(pkg.peerDependencies ?? {})) { const meta = pkg.peerDependenciesMeta?.[dep]; if (meta?.optional) continue; // an optional peer is not a reason to keep anything push(peeredBy, dep, name); }}const rows = [];for (const [target, replacement] of Object.entries(REPLACEABLE)) { if (!(target in directDeps) && !installed.has(target)) continue; const runtime = (requiredBy.get(target) ?? []).filter((n) => n !== target); const peers = (peeredBy.get(target) ?? []).filter((n) => n !== target); // Packages that only this target needed, and would be orphaned with it. const orphans = Object.keys(installed.get(target)?.dependencies ?? {}).filter((d) => { const others = (requiredBy.get(d) ?? []).filter((n) => n !== target); return others.length === 0 && !(d in directDeps); }); const blockers = [...new Set([...runtime, ...peers])]; rows.push({ target, replacement, direct: target in directDeps, blockers, removes: blockers.length === 0 ? 1 + orphans.length : 0, orphans, });}if (rows.length === 0) { console.log('None of the replaceable packages are installed.'); process.exit(0); }let net = 0;for (const r of rows) { const head = r.blockers.length === 0 ? 'removable' : 'stays'; console.log(`\n${r.target} -> ${r.replacement}`); console.log(` direct dependency: ${r.direct ? 'yes' : 'no (pulled in by something else)'}`); console.log(` verdict: ${head}`); if (r.blockers.length) console.log(` kept by: ${r.blockers.join(', ')}`); if (r.orphans.length) console.log(` orphaned with it: ${r.orphans.join(', ')}`); console.log(` packages removed: ${r.removes}`); net += r.removes;}console.log(`\nTotal: ${rows.length} of 8 candidates present, ${net} packages actually removed.`);
Three details carry the whole result.
It reads both dependencies and peerDependencies. Reading only the first is exactly how I missed react-native-pager-view.
It skips peers marked optional: true in peerDependenciesMeta. An optional peer works fine when absent, so it is not a reason to keep anything installed. Without that check the script biases hard toward "stays" and stops being useful.
It counts orphans. Removing one package does not always remove one package.
What came out, and the one that survived
Here is the run against a tree containing the five candidates I had on hand, plus react-native-tab-view to represent a project with tabs.
Four of five come out. The one that stays is react-native-pager-view — the exact swap I had assumed would be the easy win.
Note also that @gorhom/bottom-sheet removes two packages by itself, because it brings @gorhom/portal along and nothing else uses it. The biggest payoff sits on the hardest migration in the list. Work through these in order of difficulty and you will do the least valuable ones first.
Orphaned versus shared
This tree also has invariant in it, required by both @gorhom/bottom-sheet and @react-native-community/datetimepicker.
The script did not count it as an orphan, and that is correct. Drop the bottom sheet alone and invariant stays, because datetimepicker still wants it.
That answer is only right for one swap at a time, though. Remove both and invariant goes too. The script deliberately stops short of modeling combinations: enumerating them makes the output unreadable and pulls attention away from the question you actually have, which is what to do first.
If you do want exact counts for a batch removal, take the planned removals as arguments, strip them all from requiredBy up front, and then count orphans. It is a few lines of change. I have left mine as-is, because migrating one package at a time and watching what breaks is faster to debug than migrating four and bisecting afterwards.
Inside a Host, there is no flexbox
There is a second axis to check, independent of dependency counts.
Expo UI components live inside a Host, and layout inside that boundary is handled by SwiftUI and Compose layout primitives — not Yoga flexbox. That behavior is documented, not a surprise, but it is easy to skim past.
Which means even a genuine one-line import swap moves a layout engine boundary at that spot. If the surrounding tree was built around flex: 1 and justifyContent, the arrangement can shift the moment you swap.
Putting both axes together gives a running order:
Layout boundary unchanged
Layout boundary moves
Dependency drops
Start here
High value — pilot it in one app first
Dependency stays
Only if you want the native look
Defer
@react-native-picker/picker and @react-native-masked-view/masked-view tend to land top-left: the replacement is self-contained. @gorhom/bottom-sheet lands top-right, because it wraps an entire existing layout inside itself.
Ordering the work when components are shared across apps
I run several related apps that share a common component layer. That structure means the decision does not stay inside one app.
Editing the shared layer applies everywhere at once. Fast, but a change that moves layout boundaries in shared code multiplies the number of screens you have to look at.
So the order I settled on:
Run the script in every app and build a table of "packages removed." Any app showing zero drops out of this round
Where the counts tie, start with the app that ships least often — there is more room to back out
Any swap that touches shared components gets proven in a single app first, then moved into the shared layer
Separately, ask whether the peer holder itself — react-native-tab-view, in my case — could go. If it does, the whole table changes
That fourth item only occurred to me during this audit. It reframes the work: instead of optimizing the migration, you look at the precondition that decides whether the migration is worth anything. I do not have an answer yet. Dropping a navigation library is not a decision you make to save one npm package.
If you are considering these swaps, save the script and run it before opening an editor:
node scripts/expo-ui-dropin-audit.mjs .
If the last line reads 0 packages actually removed, then migrating for dependency reduction does not pay off in this project right now. That is the moment to check whether you have a different reason — wanting the screen to feel native — and to decide on that basis instead.
Had I run this first, I would have reordered the work before spending time on the pager-view swap. The check takes under a second. Thanks for reading, and I hope it saves you the same detour.
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.