So You Want To Dynamically Update Your React Native App

At work we recently shipped our first React Native components inside an app, and I’ve been mulling how (and if, honestly) we want to allow sending new code over-the-wire. I’ve written about dynamically updated React Native apps before in the abstract, but let’s walk through how this could go down in reality.

Prologue

Here’s a quick refresher on how React Native views are created. You need an instance of RCTRootView, which can be instantiated with an RCTBridge:

RCTBridge *bridge = ....;
RCTRootView *reactView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"MyView"];
[self.view addSubview:reactView];

The RCTBridge is the key; you can think of the bridge as being the “brain” of a React view (or a set of React views). How do we make a brain? Like this:

NSURL *bundleURL = ...;
RCTBridgeModuleProviderBlock block = nil;
NSDictionary *launchOptions = nil;
RCTBridge *bridge = [[RCTBridge alloc] initWithBundleURL:bundleURL moduleProvider:block launchOptions:nil];

We can give it a URL (whose content should yield some JavaScript), and the bridge will automatically query the URL. The URL can be an HTTP URL or a URL to somewhere on the filesystem.

While iterating on your code, you normally instantiate your RCTBridge using the React Native development server URL; in production, you swap it for a static file URL. This means your React Native code is tightly coupled to your app releases — if you want to update just your React Native code, gotta wait through another review cycle. Can we avoid that?

Always Ask The Server

The naive solution is to replicate how the React Native development environment works: use a server URL. You set up an endpoint on your server, like “/react-native-bundle”, which returns the whole bundle.

To a user, here’s what that will probably feel like:

  • Open the app
  • See like a loading screen while the app downloads the bundle
  • Move on to something else, because ain’t nobody got time for that

You quit the app because the bundles are actually quite big — even a fairly simple React Native app is probably over a megabyte. On cellular connections, with unpredictable bandwidth and latency, this doesn’t make for a sticky experience.

But wait — we’re engineers, we can optimize this! Right? We can…

  • Use HTTP ETag caching to avoid downloading the same big file over and over. This means you only have one long download per update, plus some small (but not negligible) latency when querying the server
  • Maybe avoid an initial request by warming the HTTP cache with an bundled copy of your React Native code (using NSCachedURLResponse) — but if you’ve already updated your server copy by the time the app is out, then this doesn’t help

This “server-first” strategy is easy to implement, but might not be great for your users or the long-term health of your app. So let’s cross off initializing RCTBridge with an HTTP URL, and try a file-first strategy.

Eventually Ask The Server

If we can’t depend on the server sending us code in a timely manner, we need to initialize our bridge with a file URL; asynchronously, the app will download the latest version of our code. The next time the app starts, it uses the code it retrieved.

The React tutorial suggests a file retrieved via NSBundle, which has one gotcha: files in your bundle are read-only. If we want to update our file, we need to store it somewhere like the app’s Documents directory. A simple way to deal with this could look like:

// excuse my pseudo-code
NSURL *bundleURL = [self URLForCodeInDocumentsDirectory];
if (![self hasCodeInDocumentsDirectory]) {
[self copyBundleFileToURL:bundleURL];
}

RCTBridge *bridge = [self createBridgeWithBundleURL:bundleURL];
[self createViewWithBridge:bridge];
[self downloadNewCodeToURL:bundleURL];

The trick here is that the download method will write its contents to the same file we use to create our bridge. Our server can use the same ETag technique we mentioned earlier, which will limit data consumption.

It’s a win for both users and developers, with only a bit more work on the developer-end. The app always loads instantly, it will eventually be running with the latest code from the server, and our caching optimizations minimize bandwidth. Time to pack up and go home, right?

Updates At Scale

What I described will work, but has some complications “at scale”. I think the biggest flaw is that it only has a notion of the “current” version of the code on the server — we have no explicit versioning of our React code, which smells like sad times:

  • How do App Store-level updates interact with your React Native updates? Do they supersede any previously downloaded code or is there an intelligent versioning system?
  • How do you deal with upgrading a client that is >1 iteration of React Native code behind?
  • How do you deal with new JavaScript meant for new versions of React Native? (i.e. a user running old code built against React Native 0.8 tries to download new code built against 0.9)
  • React Native bundles can get pretty big — can’t we just send the diffs and apply them on the client using DiffMatchPatch? How do you deal with diffs between arbitrary versions?

These could be pre-mature worries, but if you’re going to make a bet on continuously deploying React Native apps then it’s prudent to explore them.

Maybe an opinionated framework could relieve us of re-implementing answers to these questions. That framework would expose both a client and server library that work together to ensure a dependable experience for our users and a sane workflow for developers. If that sounds like the right direction, drop a note on this post!

Why Bother?

It feels like making React Native updates work on big projects will be a fair bit of work. How do we know if it’ll pay off? The honest answer is that it depends on your situation and priorities.

Lately, my priority has been to get us learning faster, and the App Store review process imposes a hard limit on how quickly we learn. If you identify with that, then maybe you’ll identify with this too: