blog / what i learned shipping a react native app to the stores
what i learned shipping a react native app to the stores
- Mobile
- React
- Career
let's get straight to it. what do you expect to happen when you run this on a legacy expo app?
npx expo install expo --fix
the intuitive answer is usually "it upgrades expo and the libs along with it, done." that makes sense to think, the command exists for exactly that. except nope! =P
on a legacy app, a few versions behind, something else happens: expo goes up, and then a native library breaks. you update it, and the next one breaks. you update that one, and the first one comes back. it turns into a hydra, cut off one head and two more grow...
this post is the story of how i escaped that hydra with a two-line patch, and how that patch expired on the date the play store picked, not mine. at the end i say what i'd do differently.
for anyone just starting out: "sdk" here is the bundle of versions expo pins together (react native, react, and the expo-* libs that talk to android and ios). and a "native library" is one with actual android/ios code inside it, not just javascript, so it has to match the react native version you're running. that matching is what breaks.
how i ended up here
the beginning is boring: i needed to ship one fix. one. i wasn't there because i wanted to modernize anything, there was no "update dependencies" ticket, it was just a bug to fix in an app that had been running in production for quite a while.
problem is, the app didn't build anymore. and the obvious path to make it build again was bumping the sdk. so i ran the command up there, and met the hydra.
why this matters in practice
before i go on with the story, an aside that explains the despair: on the web, a bad deploy is fixed in five minutes. on mobile, it isn't.
the cycle for ONE bug fix in production starts with the build, which is already a wait:
eas build --platform ios --profile production
then you send it to app store connect:
eas submit --platform ios
and then comes the part with no command: waiting for the store's review (days, not minutes) and, once approved, hoping the user updates the app. that last part is entirely out of your hands.
it's the fourth step that gets you. even after the stores approve it, whoever has the buggy version keeps it until they update. there's no "i fixed it so everyone has the fix," which is the mental model you bring from web. here you depend on someone feeling like updating the app.
think of it like this: web is swapping the poster in a shop window, you swap it and you're done. mobile is printing and shipping a catalog, if it came out wrong that's it, the wrong version is in people's homes and you're not getting it back.
so when i say the app didn't build, understand: every repair attempt cost a build queue, and every mistake cost days.
the patch
with the hydra growing, i decided the opposite of the obvious: don't bump the sdk. work around whatever was blocking.
what was blocking was a null coming from the system inside one of expo's own libraries, in the bit that reads the permissions declared in the manifest. the native code assumed the permission list always existed:
// inside a native lib, not in my code
return requestedPermissions.contains(permission)
except android can hand that list back as null, and then the app goes down. the fix is literally one ? and a default value:
return requestedPermissions?.contains(permission) ?: false
a bug that wasn't mine, in code i didn't control, in a lib i couldn't bump without dragging the whole hydra along. so i made a patch on the dependency: a diff versioned in my own repo, that goes into node_modules on every install. two lines, no sdk touched, and the fix shipped.
(and this was before having claude around to throw a gradle stack trace at and scream SOS. it was me, stackoverflow and a lot of patience)
what i didn't know i was buying
the patch held! for months. and in the meantime i was learning, the hard way, that store review rejects you way more over policy than over code.
i got rejected for an "invalid" privacy policy. the link was right, the site was up, everything fine on my end. except the reviewer, from another country, couldn't reach the site. and for whoever is reviewing that looks exactly like a broken link, right? apple requires the link in the app store connect metadata and accessible inside the app, so if it doesn't load for them, rejected.
i got rejected over a badly declared permission too. in app.json it's not enough to request the permission, you have to say what for:
{
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "used to photograph the product when placing an order"
}
}
}
that text is the purpose string, and apple requires it to completely describe your use of the data. "we need your camera" doesn't pass. you have to say what for, permission by permission.
and there's sign in with apple: if the app offers social login (google, facebook), apple requires an equivalent option that limits collection to name and email and lets the user hide their email. for a public app with social login, that's the one. not optional, no way around it.
each of those was a days-long round trip. and in the middle of it, the queue. on the eas free plan builds go into a shared queue and the timeout is 45 minutes (2 hours on paid plans), so you wait, and sometimes you find out at the end that it failed. and then, back into the queue!
debugging mobile builds is expensive for that reason, every attempt costs.
oh, and testing, right. testflight isn't intuitive at all for non-devs: testers who don't get the invite, testers who get it and can't find the app. half of my qa support was teaching people how to install the app, not how to test it.
the day the bill came
and then the play store started rejecting for a reason with no possible workaround at all.
there was nowhere left to run. i removed the patch, bumped the sdk, and the commits that followed were me rebuilding piece after piece of the app to fit the new libs. the hydra i'd dodged months earlier was right there, whole, waiting. it was an epic battle, ok?
the commit where i face it carries a message that sums up my state of mind at the time pretty well: "updated sdk version for i have faith" (git hygiene is my passion) xD
and here's the lesson i took with me for good: a workaround isn't debt you choose when to pay. the one setting the date is the store, the os, the lib that stopped getting support. you choose to postpone, you don't choose until when.
what i'd do differently
- make the patch again, guilt-free: it was the right call to ship that fix that week
- but put it on the roadmap, with my own date: "bump the sdk by month X", before the store picked the date
- bump the sdk one version at a time instead of skipping several, so you know which little lib broke
- treat privacy policy, purpose strings and social login as part of the feature, not end-of-line bureaucracy
- count the build queue and the review into the deadline i promise the team, because they aren't "extra", they are the work
at the end of the day, what changed for me was less technical and more about the calendar. being wrong on the web is cheap, being wrong on mobile costs days of review and a bad version sitting on people's phones. so you learn to make a checklist before shipping, to use feature flags instead of "i'll fix it later," and to test on both systems before celebrating, because you fix android it breaks ios, you fix ios it comes back on android. now i get sisyphus
it's funny that the lesson isn't about react native or about apple: it's that technical debt on a platform you don't own comes due with interest, and on a date you don't pick. you can postpone quite a while, you just can't pretend the deadline isn't there. the hydra always comes back =]