"Why can't we just write this code once and have it just work across Web, Android and iOS?" As a mobile engineer who has spent both a significant portion of his career copying the exact same product from iOS to Android, I’ve asked myself this question many times.
At Ambrook, we’re lucky to have the opportunity to approach the challenge of supporting multiple platforms without the constraints of legacy engineering decisions. As a result, we’ve been able to share code across web and mobile, allowing all of our entire engineering team (currently four engineers) to contribute with little overhead.
Ambrook is building a suite of financial tools for farmers that needs to be consistent and fully-featured across desktop and mobile. So that farmers can use our tools in the field, they must work well on slower internet connections and while offline. As an early stage company, we are prioritizing rapid delivery of new features as we get feedback from our users.
When I first joined the Ambrook team, I was tasked with figuring out how to translate our existing web app to mobile. The web app was written in Typescript using React DOM and Next.js, which made it a tempting target to try using React Native. If successful, using React Native would mean that we could share code between platforms, greatly reducing both the cost of copying existing features and (more importantly) the cost of every new feature we’d need to build going forward.
At the same time, we wanted to be humble about the reasons why other companies have struggled to adopt cross-platform technologies. A common issue that we avoided is the need to work with existing native product code, a requirement that can significantly negate the productivity advantages of using a cross-platform framework.
There were three main questions that we set out to address from the beginning:
Can we define best practices and patterns around cleanly separating presentation and business logic to allow for the inevitable UI divergence between platforms?
Do third-party libraries exist that cover common app needs (ex. navigation, API access, graphing), and do they work well across platforms?
Does working cross-platform significantly speed up development time compared to just writing the code twice? Is the system easy for someone familiar with our existing codebase to be productive in?
We decided to leverage React Native as a compatibility layer between the shared business logic from our existing React (Web) codebase and platform-specific APIs. React Native allows us to bridge the differences between the UI APIs on each platform: DOM on Web, UIKit on iOS, and Android's View system. We chose to adopt this cross-platform approach because it lets us share business logic between platforms, leverage high quality open source libraries, and rapidly develop and QA new changes to the app.
Sharing Business Logic
One of the main benefits of our cross-platform strategy is that we are able to share business logic between web and mobile. In fact, all major screens in our app share the same business logic across platforms; it was easier to adapt the existing Typescript-based business logic to be platform agnostic than to rewrite the same business logic multiple times.
An area where we chose not to share code is in the UI layer above the business logic. The guiding principle that we used was to share as much code as practical (business logic and some views) while allowing for some level of divergence to allow for native-feeling UIs and to use the APIs and technologies that felt best for each platform.
On the web front, this means using CSS-based media queries to create a responsive website and using CSS for things like sticky headers. On mobile, we add support for pull to refresh and double tapping on the navigation bar to scroll to the top of a screen. For both platforms, the lowest level components (like buttons, forms, etc.) are implemented separately as part of our design system, which we talked about in a previous post.
Leveraging Open Source Libraries
By using React Native, we're also able to tap into a rich open source ecosystem that has largely removed the need for us to write platform specific native code. Our experience has been that most common app use cases have mature, well-maintained libraries.
Some of our favorite libraries in use are:
Apollo Client, a GraphQL client implementation with support for optimistic mutations, cache normalization and persistence, and client-side state. Apollo scales well from a limited persistence web environment to an offline, stateful mobile environment.
Victory, a charting library with support for many different chart types.
React Navigation, a mobile navigation library with support for tabbed navigation, modals, and navigation stacks. It also has first class URL handling, which makes bridging the gap with web very simple: just use URL-based navigation everywhere.
Rapid Local Development
Making a small code change and having it be reflected on device can take anywhere from a few seconds to several minutes in the largest apps. Reducing this incremental build time pays dividends beyond simple time savings. As the iteration time gets shorter, we’re able to both make more rapid and isolated code changes, greatly enhancing comprehension of the code that those changes are being applied to.
Both our web app (via Next.js) and our mobile app (via React Native) support Fast Refresh, allowing changes to components to be reflected in a matter of seconds without losing either app or individual component state. Fast Refresh has been indispensable. When I’m not sure of how a UI will look, I can save small changes several times in a row, tweaking one layout property until everything looks right. Frequently, I keep a browser window and simulator open side by side to reflect live changes on two platforms simultaneously.
Rapid QA on Pull Requests
As an early-stage company, we frequently ship new features and fixes to our app. In order to prevent new bugs from being introduced during this process, we made it easy for developers to manually test changes as a normal part of their review flow.
On web, we deploy every open pull request to a unique Google Cloud Run URL so that opening the new version of the app is a single click away.
For Ambrook, building a cross-platform web and mobile app from a single codebase has saved us significant time and effort. Other teams considering a similar approach can take a few points away from our experience:
You can expect near-full sharing of business logic between platforms, but having divergent UIs (either at a design system level or at an entire screen level) may be most practical and preferable to create an app that feels native on each platform.
Open source libraries currently cover most common app needs and effectively abstract away platform differences, removing the need to write native code for feature development.
The developer experience improvements in React Native (Fast Refresh and QA via QR codes) are significant and would be difficult to accomplish in native due to its use of statically compiled languages.
Finally, as a quick catch-all, a few bonus recommendations for adopting our stack:
Although most engineers on this stack don’t need to know about the underlying iOS / Android systems, you’ll still need someone with a working knowledge of the native build systems and IDEs in practice to debug build and configuration issues. This is something that the Expo community is working on.
Not all React libraries work well across both native and web, so you’ll need to be deliberate when choosing which to use.
Thanks to our lack of legacy code, we’ve been able to pick the best of breed tools for our situation: React Native on Mobile and React/Next.js on Web. This technology is instrumental in our ability to rapidly and efficiently iterate on our mission of helping to make farmers more profitable and sustainable.