Standard Notes has seen its codebase evolve many times through the years. When we first launched in late 2016, we started with three codebases: our web app (which our Electron desktop app contained), a native Swift iOS app, and a native Kotlin Android app. This was immediately untenable. Coordinating bug fixes across three platforms was hard enough, but building new features? Out of the question. We had to call our app "simple" because we literally couldn't make it anything else. (Tongue-in-cheek, but there's probably some truth to it.)
From Three to Two Codebases
In 2017 we transitioned to React Native for our mobile app, cutting down our total number of codebases to two. This felt like heaven, for a time. Now whenever we wanted to introduce a new feature, like encrypted images and files, we would need to write it "only" two times—wonderful, right? Not so. Sure, maximize your use of libraries and ensure you're not writing critical business logic twice. At some point however we reached the limit of that approach, having to write low-level streaming encryption functions in C because our web encryption library was not reusable on mobile.
But even assuming you've maximized your business logic DRYness, orchestrating new UI features for two codebases with a small team is still a doubling of effort. Our mobile and web platforms had different issues and worked on different timelines. A bug that existed on mobile but not web which required two days to fix meant that web and mobile were constantly on different wavelengths. To get the web and mobile teams to sync up on new features was a constant struggle. Building on web was also typically much faster than mobile, so mobile was always behind. The result was that neither web nor mobile saw any notable momentum of new features.
There is also no practical way to build on React Native without using a metric ton of third party libraries. Packages for React Native are their own special breed of monstrosity because the package author would be the one writing code twice: once for iOS and once for Android (and of course the JavaScript wrapper). The result is a drastic increase in library abandonment and stability issues. In addition, keeping your packages in sync with the latest version of React Native (which is notoriously difficult to keep up to date) was a nightmare of its own.
At some point we had seen enough. Two codebases—that's one too many for us. We started having dreams of being able to write code only once. Imagine how fast we could ship. Imagine the concentration of quality and focus into one place. Imagine not having to treat mobile like an afterthought anymore. Imagine dreaming of a feature and not being let down because "Ah, right! We have to build this for mobile too," and then abandoning the feature altogether due to the impracticality of doubling the effort. A single-codebase’s feature is effort enough.
One Codebase
When we finished the transition from two to one, we asked ourselves why we hadn't done it sooner. Today, Standard Notes is just one codebase. Our mobile app is just our web app with access to native mobile functionality, and it runs much better than we could have imagined. It sports 1:1 feature parity with our desktop app, a feat impossible with our old setup. It accesses the native mobile keychain, biometrics, local storage, camera, and more.
Removing the React from React Native
Our migration was quite simple really, and consists primarily of a bridge between web-land and mobile native-land. Our new mobile app still uses React Native, but is only a single component: a function that renders a webview. The webview facilitates message passing between web-land and native-land through a postMessage bridge. When web-land wants to access the device keychain, it sends a message to native-land, and native-land responds with a primitive value.
Neither native-land or web-land ever have to think about "messages" though. They're just function calls. Web-land has doesn’t know that its function calls are being converted to postMessage calls behind the scenes. Web is also using await
for a response just like it would any other async function. This is not meant to be a technical post, but at a high level, our architecture consists of each platform simply having to provide a DeviceInterface class to an Application instance, and with that interface, an application can run on any platform, even say a command line. The device interface provides access to device APIs, like the database, keychain, or camera.
A practical way to build mobile
Don't get me wrong: there's a lot to love about React Native. Its community and package ecosystem are vibrant, probably more so than many competing ecosystems. But if too you're finding that two codebases is one too many for your small team, there is a way out. You don't have to rewrite your web application UI from scratch into React Native. Instead, you can use RN as the bridge to access native system functionality from your web app core. In our case, our new mobile app is, in our estimation, many times better than our old, perpetually tailing semi-native app. Try it for yourself.
If you're starting today, you can take a similar approach to ours, or take a look at libraries like Capacitor which do something very similar, with the downsides of not having the same rich third-party package ecosystem as React Native.
Thanks for reading ✓