Onboarding thousands of users with React Native
A retrospective for companies considering React Native
By Ian Ownbey, Nick Cherry, and Jacob Thornton
In mid-2019, we committed to rewriting Coinbase’s core mobile sign-up with React Native. This decision was motivated by a few observations:
- Coinbase currently supports over 100 countries. Because different jurisdictions have different regulatory requirements (e.g. Know Your Customer, Anti-Money Laundering), our sign-up experience needs to be dynamic — adapting to each user’s location and identity profile. Unfortunately, these flows were some of the oldest, most complex parts of our mobile apps; even incremental changes could be expensive to implement. In order for us to support more countries faster (and more safely), we needed to make our mobile onboarding code much more robust. Given the state of the app, we estimated that rewriting the sign-up flows from scratch would be significantly faster than trying to refactor the existing code.
- As mentioned above, our sign-up flow involves a great deal of business logic. Rewriting the sign-up experience natively would require a non-trivial amount of code duplication, and maintaining parity between our iOS and Android apps (something we’ve struggled with historically) would be costly. However, if we implemented the module with React Native, we could target multiple platforms. This would allow us to re-use most (if not all) of the business logic and ensure behavioral consistency between all of our apps.
- Coming into this project, the Coinbase mobile apps were fully native and the Coinbase Pro mobile app was written entirely with React Native. We wanted to share our new sign-up experience with both products, and we expected that integrating a React Native package would be less expensive than embedding native modules.
So far, the project has been a success, increasing our team’s velocity and enabling us to share the sign-up flow across products and platforms. Ultimately, we feel like we’ve made the right decision investing in React Native. With a team of five engineers, we’ve rewritten the entire sign-up flow from scratch and shipped to both Coinbase and Coinbase Pro on iOS with support for Android being worked on now. We’ve learned a lot about React Native in the process and want to take the time to share the highlights and lowlights of our experience.
The Good Parts
If we were to reduce the benefits of React Native to a single word, it would be “velocity”. On average, our team was able to onboard engineers in less time, share more code (which we expect will lead to future productivity boosts), and ultimately deliver features faster than if we had taken a purely native approach. We accomplished this while maintaining a high bar for quality, and we believe the finished product is indistinguishable from a fully native app. There were many factors that contributed to these wins, and in the following sections, we’ll discuss some of the most important ones.
Components
Components are composable JavaScript functions that accept immutable inputs (called “props”) and return React elements describing what should appear on the screen. They are the fundamental building blocks of any React app and make it easy for engineers to split their UIs into self-contained, reusable pieces.
For the onboarding rewrite, we created a family of components comprised of form elements (e.g. buttons, text inputs), text elements (e.g. headings, paragraph text), layout elements (e.g. screens, spacers), and more complex UI widgets (e.g. date inputs, progress bars, modals). With the exception of the date input, all components were written entirely in TypeScript.
Most of the core components were created early in the project’s lifecycle. These reusable building blocks enabled engineers to move very quickly when building out screens, which is mostly an exercise in describing interfaces with declarative markup. For example, below is the code used for creating our phone verification screen:
const config = {
name: 'PhoneScreen',
formShape: { phoneNumber: '', countryCode: 'US' },
optionsShape: { analytics: {} },
};
export default createScreen(config, ({ Form, isLoading, options }) => {
const { phoneNumber, countryCode } = useFields(Form);
const [submit, isSubmittable] = useSubmitter(Form);
return (
Set up 2-step verification
Enter your phone number so we can text you an authentication code.
{...phoneNumber}
countryCodeValue={countryCode.value}
label="Phone"
keyboardType="phone-pad"
onCountryCodeChange={countryCode.onChange}
placeholder="Your phone number"
returnKeyType="done"
validators={[
[required, 'Phone number is a required field.'],
[phoneNumberValidator, 'Please enter a valid phone number.'],
]}
/>
)
});
Each component was designed to be themeable from the start, which helped us adhere to a design system and ensure visual consistency across the module. The theme provider also makes it trivial to uniformly adjust styling (e.g. colors, typefaces, sizes, padding, etc.) either globally or for a given set of screens.
Lastly, because components lend themselves nicely to encapsulation, we were often able to parallelize development efforts around these units, as engineers could work on various parts of the app with minimal dependence on one another. This was beneficial to our velocity and our ability to schedule work effectively.
Fast Refresh
Fast Refresh is a React Native feature that allows engineers to get near-instant feedback for changes in their React components. When a TypeScript file is modified, Metro will regenerate the the JavaScript bundle (For incremental builds, this typically takes less than a second), and the simulator or development device will automatically refresh its UI to reflect the latest code. In many cases, the full state of the application can be retained, but when dependencies outside the React tree are modified, Fast Refresh will fall back to performing a full reload. When a full reload occurs, the app loses most of its state, but engineers still get the benefit of seeing the effects of their changes nearly instantly.
Fast Refresh significantly boosted productivity when creating core components and screens, as both tasks are very visual and iterative in nature.
The functionality was also helpful when developing against API endpoints, as it enabled engineers to tweak the API client and perform network requests without needing to configure the application state (e.g. access tokens, form data) after every change.
Even when UI and state retention were not particularly relevant (e.g. when working on business logic within the framework), the ability to manually test code in seconds (as opposed to tens of seconds) is a huge win for productivity, as engineers are not constantly making the trade-off of either context-switching or sitting idly while they wait for the compiler.
Learning Curve for React/Web Engineers
There are a few notable differences between writing React code for a React Native app versus a web-based app:
- The component primitives are different. For example, in React Native engineers will use View and Text elements, where web engineers would use div or span.
- Unlike CSS, styles applied to elements in React Native do not cascade. For example, setting a font-family in a root-level container does not apply the typeface to its children.
- React Native often requires engineers to have some familiarity with native development tools, like XCode and Android Studio. For example, even when using a third-party library that requires no Objective-C coding, an engineer may need to modify a permission in a plist file.
- Mobile devices have features, limitations, and quirks that web engineers may not be familiar with. For example, logic to retry requests during periods of intermittent connectivity is more nuanced for mobile apps than for web.
In our experience, most of these differences were easily surmountable for web engineers, who were able to be productive in a React Native context almost immediately. Considering that the vast majority of the sign-up flow was written in TypeScript (i.e. only a tiny portion of the app required any custom native code), this substantially contributed to our velocity.
It should be noted that developing a deep familiarity with the native environment is undoubtedly the most challenging part of transitioning from web to React Native. This is also one of the reasons why it is invaluable to have native engineers as part of teams working with React Native.
Code-Sharing
iOS + Android
The vast majority of the new sign-up flow was written in TypeScript that is functional on both iOS and Android devices out of the box. One exception to this was needing to write our own native module to take advantage of the native iOS Date Picker, which will be required to productionize for Android. Even with this extra overhead, we expect that the velocity benefits of sharing an estimated 95% of the code and having parity between the two platforms will be well worth the cost.
Coinbase + Coinbase Pro
We shipped the new sign-up screens as an internal NPM package that is currently being utilized by both the Coinbase app, written in Swift, and the Coinbase Pro iOS app which is written entirely in React Native. Adding support for Coinbase Pro cost an estimated two weeks of engineering time, with most of the efforts going into 1) explorations around how to best support both greenfield and brownfield React Native implementations and 2) abstracting authentication logic (e.g. granting and refreshing access tokens) to allow host applications to provide their own custom implementations. Similar to supporting both iOS and Android, we expect that the benefits of sharing the entire sign-up screens codebase between Coinbase and Coinbase Pro will be worth the extra overhead.
React Native + Web
We have a number of internal libraries at Coinbase which are utilized in our web stack, and by utilizing React Native we were able to take advantage of some of them. Especially useful was an internal library that was written to manage form state, validation and submission. Since this was an important part of the project we were able to use it with pretty minor changes, enabling the library to be shared between web and mobile. Conceivably, React Native and web could share any code that 1) can be extracted to an NPM package and 2) isn’t tightly coupled to UI primitives (e.g. div, View). For example, API clients, localization modules, and various utilities (e.g. crypto address parsers, currency formatters) could all be candidates for sharing.
TypeScript
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. It is commonly used for developing React Native apps and has capabilities similar to Swift and Kotlin.
While TypeScript does come with a learning curve and possibly a higher upfront cost to write (compared to vanilla JavaScript), we believe the safety, ease of maintenance, and IDE enhancements it enabled were invaluable. Without a type-safe abstraction on top of JavaScript, many of the framework-level changes that were required throughout the project would have been significantly more challenging and error-prone. Especially given the slow release cadence (relative to web) of mobile apps, we feel that the extra confidence provided by a typed language is essential.
The Hard Parts
While our overall experience with React Native has been positive, the journey was not without its bumps. In the following sections, we’ll discuss some of the challenges we faced, including the native-to-React-Native learning curve, complications of targeting multiple platforms, and the sharp edges that come with emerging technologies.
Learning Curve for Native Engineers
React Native is built on a multitude of technologies. TypeScript is transpiled into JavaScript with Babel, then served by Metro (a relative to Webpack). The compiled JavaScript interacts with React Native APIs that communicate “over the bridge” to Objective-C or Java. React Native also provides global variables to polyfill functionality one would expect to find in a modern browser context, like fetch or FormData. Most of the application code is written with React, using TSX to mimic the ergonomics of HTML and CSS-like props to style elements. Engineers may come across tutorials featuring class-based components integrating with Redux, but the community is migrating to functional components, hooks, and context. Coding styles and conventions are enforced by TSLint/ESLint, then prettified by Prettier. Jest is the unit test runner, and we rely on Detox for E2E tests, possibly adding Enzyme in the future. Engineers spend most of their time in VSCode, a terminal shell, and their debugger of choice (e.g. Chrome dev tools, Reactotron).
Web engineers have probably had at least some exposure to most of the technologies and concepts mentioned in the previous paragraph, which is a big part of why the React Native learning curve is relatively low for them. Native engineers, on the other hand, may not be familiar with many of those terms, making the barrier to entry steeper. They’re not just learning a new language. They’re learning a new meta-language that transpiles into a new language using new build tools, a new library (React) that embraces new paradigms, a new framework (React Native), and a new ecosystem, all emerging from the complex and quirky evolution of JavaScript development over the past decade.
Another factor that makes the transition to React Native challenging for native engineers is the modular nature of the landscape. With native development, engineers spend their time in a powerful IDE (XCode, Android Studio) that does most of the heavy lifting in regards to managing the local environment. In contrast, React Native development often requires engineers to be more hands-on — installing dependencies and starting servers from the command-line, setting up build and release scripts, and configuring and connecting to a third-party debugger.
Finally, where mobile engineering has centralized authorities (Apple, Google) who maintain many of the foundational tools and define best practices for the platform, the React Native ecosystem is much more decentralized. While decentralization certainly comes with its benefits, it can create a great deal of confusion for newcomers. Often there is no single source of truth to rely on, and engineers may need to sift through several resources (e.g. Github documentation, Medium posts, StackOverflow threads, source code) to find what they’re looking for. In the process, they may encounter information that is conflicting and/or outdated, as the JavaScript ecosystem tends to evolve rapidly.
Possible Future Improvements
- Invest in custom tooling to improve the local development experience for native engineers.
- Have each native engineer spend a significant portion of their time pairing with one or more web engineers for the first quarter working with React Native.
- Maintain a library for commonly referenced React Native documentation, written with native engineers in mind as the primary audience. Materials would include up-to-date best practices for developing React Native apps at Coinbase, tutorials, troubleshooting guides, etc.
- The React Native team is also continually working to update their documentation to be more friendly to engineers across platforms.
Native Interoperability
Sometimes an app may need to access a platform API for which React Native doesn’t have a corresponding module yet. Or a particular feature might benefit from a performance boost that can only be achieved through native code. For scenarios like these, React Native offers native modules, which allow JavaScript to delegate tasks to custom native code.
When developing the Sign Up screens we didn’t encounter any performance issues that couldn’t be addressed purely with JavaScript. We did, however, need to write our date input using a custom native module, as we wanted to present the UIDatePicker that iOS users are accustomed to. We successfully implemented the component, but the developer experience was less than ideal:
- While native modules can be written in Swift and Kotlin, React Native only supports Objective-C and Java by default. Additionally, native modules must be exported using a set of macros that come with React Native. Between the Objective-C (We didn’t prioritize supporting Swift.) and the macros, writing code for a native module can feel a little awkward/clumsy.
- The core React Native components (e.g. TextInput) are not easily extendable through native code. If a component needs to behave similarly to a core component, but also requires custom native code, engineers may need to re-implement functionality that typically comes for free with core components. An example of this is triggering focus and blur callbacks for our custom native input components.
- Changes to custom native modules require a rebuild, the same as it would for a native app. As a consequence, engineers working on native code are forced to go back to a less productive environment without things like Fast Refresh.
- Data sent over the javascript-to-native bridge is not typesafe, which means that types need to be maintained on both the native and the TypeScript side.
Possible Future Improvements
- Add Swift and Kotlin support for native modules.
- If we find ourselves dealing with the same category of problem (e.g. focusing and blurring inputs that rely on custom native modules) more than once, consider investing in a lightweight framework to standardize and abstract the common behavior or upstream the changes into React Native.
- Maintain a sandbox environment to improve build times when writing native modules.
- Describe native APIs with JSON and use a tool like quicktype to generate types for TypeScript, Swift, and Kotlin, making it easy to keep JavaScript and native types synchronized.
- Coming React Native features like CodeGen will greatly change how Native Modules work and should solve a lot of these problems.
Platform Differences
While the majority of TypeScript code works on both platforms out of the box, there are some behavioral/styling inconsistencies between foundational UI components on iOS vs Android. Many of these differences are acknowledged in the React Native documentation, but could easily be overlooked if engineers aren’t diligent about testing on both platforms. React Native provides two convenient options to implement platform-specific behavior when it is necessary (the Platform module and platform-specific file extensions), but this introduces branching logic between platforms.
Possible Future Improvements
- Ensure that each React Native team at Coinbase has at least one native engineer from each platform (iOS, Android). These individuals will likely have better intuitions than web engineers when it comes to the expected behaviors and nuances of platform-specific native components.
- Maintain a thorough integration/E2E test suite that must pass for both iOS and Android simulators before any code can be committed to master. This should help protect against platform-specific defects that may have been missed in development.
- Require Pull Requests to include screenshots and/or gifs of new features working on iOS and Android, to normalize and enforce manual testing on both platforms.
- Introduce an automated visual testing framework to prevent unintended UI changes.
Debugging
One surprising discovery was that React Native debuggers do not necessarily evaluate JavaScript in the same engine as the simulator / device. With iOS devices, React Native runs on JavaScriptCore, and with Android devices, React Native can run on Hermes. However, when debugging is enabled, JavaScript execution is delegated to the debugger. So when debugging with Chrome dev tools, the Javascript is being evaluated by the V8 engine in the web context, not JavaScriptCore or Hermes in the native context.
In theory, these engines should all behave the same, as they’re all following the ECMAScript Language Specification. However, on two occasions, we were haunted by bugs that seemed to appear at random, then reliably work the moment we tried to examine the behaviors more closely. After a great deal of head-scratching in both cases, we realized that the bug was only present when debugging was disabled. The root cause of the issues had to do with the fact that global variables (specifically the fetch function and the Date object) had slightly different behaviors depending on which engine was running the code. Other teams have also cited different performance characteristics depending on whether debug was enabled (see “message actions” section).
It should be noted that the overwhelming majority of JavaScript behaves identically, regardless of whether debug is enabled. Furthermore, now that we’re aware of the potential pitfalls of debug mode, we expect any future issues to be easier to identify.
Possible Future Improvements
- Encourage new engineers to rely primarily on debuggers like Reactotron or Safari developer tools (which evaluate JavaScript using the simulator/device’s engine) and only resort to Chrome when one of its unique features is valuable.
- Encourage new engineers to use whatever debuggers they prefer, but be very explicit in communicating engine-related pitfalls, so they have a better chance of identifying this category of problem if they encounter it.
- Maintain a thorough integration/E2E test suite that must pass for both iOS and Android simulators before any code can be committed to master. This should help ensure that bugs aren’t masked by discrepancies between the engines of the simulator/device and a debugger.
Moving Forward
Overall, our team had a markedly positive experience with React Native. Component reusability, Fast Refresh, and ease of web engineer onboarding have all contributed meaningfully to the velocity of the project. As the team moves quickly, TypeScript and unit + E2E test suites should help ensure that it also moves safely. It is also worth noting that we did not encounter any performance issues that couldn’t be solved with JavaScript alone.
We are excited to continue our investments in React Native, with a particular internal focus on the following:
Developer Experience
While some aspects of working with React Native could be described as delightful, the developer experience is not without its quirks and may feel very hands-on at times, particularly for engineers who don’t come from a web background. For example, working with a React Native app requires installing and configuring several system-level dependencies (e.g. Homebrew, Node, Yarn, Ruby, Bundler, XCode, XCode Commandline Tools, XCodeGen, OpenJDK, Android Studio, Watchman, React Native CLI, TypeScript), synchronizing NPM packages (Javascript and native), synchronizing assets (e.g. fonts), managing a local Metro server with simulators/emulators, and connecting to a standalone debugger.
Education and Onboarding
React Native is a powerful technology that web engineers will be able to go far with. But unlocking its full potential requires a deep understanding of iOS and Android platforms, which can only be acquired through years of mobile experience. That is to say, for React Native to be truly successful at Coinbase, we need the help of our native engineers.
As mentioned previously, the learning curve for native engineers will be steep; in addition to React Native, they’ll also need to become familiar with several layers of technologies from the web ecosystem. If you are considering this on your team, we recommend you do everything you can to setup your engineers for success. This might include creating content that will help them navigate the landscape (e.g. tutorials, clearly defined best practices, troubleshooting guides), regularly scheduling pairing sessions with web engineers, and/or incorporating guard rails and tooling into our codebases sooner rather than later.
If you’re interested in technical challenges like this, please check out our open roles and apply for a position.
This website contains links to third-party websites or other content for information purposes only (“Third-Party Sites”). The Third-Party Sites are not under the control of Coinbase, Inc., and its affiliates (“Coinbase”), and Coinbase is not responsible for the content of any Third-Party Site, including without limitation any link contained in a Third-Party Site, or any changes or updates to a Third-Party Site. Coinbase is not responsible for webcasting or any other form of transmission received from any Third-Party Site. Coinbase is providing these links to you only as a convenience, and the inclusion of any link does not imply endorsement, approval or recommendation by Coinbase of the site or any association with its operators.
Onboarding thousands of users with React Native was originally published in The Coinbase Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.