What We Learnt from Code Sharing Between React and React Native
With React Native on mobile devices, React was the logical choice for the web. This allowed us to have one team on the project working on the single-page application, as well as the mobile app. In an ideal world this one team could also write code only once, since with React and JavaScript it’s the exact same technology on all platforms.
We actually invested some time in researching what parts of a React app can be easily shared between the Web and React Native and documented our findings here:
Our Expectations How Things Would Go
After our research we decided on a partially shared codebase. Our expectations were that we could share all
Apart from less code, the second huge benefit we expected, is quite an important one for an app’s lifecycle and maintenance. Bugs only have to be discovered and fixed once and can automatically be re-deployed on all platforms.
The biggest challenge we faced, and knew we would face, with the shared code base is integrating changes in a development environment and releasing packages while upholding backwards compatibility.
How to Not Break All Your Consumers at Once
When writing a library, compared to an app, a lot of considerations have to be made when releasing a new version. Even the smallest change can affect a consumer if a consumer-facing API change has been released and the package’s version is not properly updated. npm, by default, installs a package with the version number being prefixed with a caret (^1.2.0
) which allows npm to upgrade the package automatically within a certain range. If all maintainers implemented Semantic Versioning, this would be a good idea. Unfortunately that’s not the case.
To prevent issues with our two consuming clients, we decided to automate releases with semantic-release. semantic-release can, based on the repository’s git history, figure out whether breaking changes were implemented, and increase the version number in a semantically meaningful way.
Although a nice release workflow is very helpful, it won’t help to integrate a shared package during development. Software Engineering principles like TDD can help a lot with this, but a User Story usually has more user-concerned acceptance criteria. So usually the code in a shared library has to be tested on at least one consuming application. Tools like Lerna help with shared package development, but we opted to link the packages with npm link
ourselves and run the required tasks by hand (e.g. TypeScript compilation steps). This also worked quite well, with two notable exceptions:
- A
node_modules
folder within a dependency results in a crash during React Native’s Babel transformations. - Metro, A JavaScript Bundler for React Native, doesn’t support symlinks as described in the repository’s very first issue. And to our luck,
npm link
does exactly that; symlink your local code tonode_modules
. This means, the code is not properly readable and interpretable by Metro.
Unfortunately, we never found a proper solution to our problem. We ended up testing changes to the shared library on the web and then installing the package “manually” in the React Native app for local testing.
Challenging Our Very Own Redux Best Practices
At the time of implementation we gathered and combined best practices from online articles and our own experience with Redux. We had finished a couple of large-ish React (+ Redux) projects before and (thought we) were well prepared for the task at hand. Looking back, we’d do some things quite differently.
ID Lists
We heavily normalized our data which led to large arrays of entity identifiers. Think of domain objects like Groups, Members, Post and Comments. These objects are heavily interlinked (a group has members and posts, a comment belongs to a post and was written by a member, etc). It’s worthwhile noting that actually normalizing the data was not the problem, and reading and rendering the data was easy and nice.
What we did struggle with, though, were data mutations. We decided to take a modern approach and implement all user interface actions with an optimistic update approach. This means that a client will act as if an action was successful before it receives the server’s response, rather than showing a loading indicator and then displaying a successful state. When a comment is deleted you need to clean all related data: you probably need to clean up the ID array of the user object, the ID array of the group object, and the ID array of the parent post object. To be able to implement something like this you need to keep your mental model up to date and remember which parts of your state could reference the mutated data. We discovered similar problems with creating new data or with error handling of linked objects (e.g. a photo in gallery, attached to a post, failed to upload).
During development we started testing whether we could skip ID arrays and work with selectors directly. For example selectPostsForUser(id: number)
would iterate over all available posts that match the user ID, rather than looking up the user object and reading from a list of referenced posts. Unfortunately, performance can suffer quite a bit from this. Luckily the React community offers libraries like reselect that help by using memoization.
Another downside of not using ID lists is the sorting order of data lists. When fetching a list of posts from the server, the server can dictate the order and the frontend can rely on the provided data. With the data being kept in a hash table, it’s not sorted anymore, and either the sorting has to be stored alongside or the order has to be done in the selector.
Data Lifetime
We’ve already developed other online communities with a focus on user-generated content — but a Social Network definitely brings more challenges especially in terms of data lifetime and timeliness.
In 2017, 400 hours of video were uploaded to YouTube every second. In practice content is not only created, but also edited and deleted. With a classic, server-side rendered platform, the user is provided with the latest content automatically. In an SPA environment previously fetched content should be cached on the client, while still being periodically refreshed.
We tried a couple of different approaches on how to tackle this. At first we checked whether the data was still valid just before rendering, and dispatched a refetch if it was stale. As explained in our research findings, we decided not to share Redux containers, so we actually had to implement these changes on the web and in the React Native app. We quickly realised we were doing the same data checks all over the place, and decided to go another route.
We ended up keeping this solution, but realised it’s not quite optimal for the platform’s User Experience. Although we made sure that refreshes only get dispatched on specific user interactions, it can still result in weird UI behaviours. A better solution could be to notify a user about new data but only refetch on an explicit user action, like Twitter does for example.
Outlook
Overall we are quite happy with our setup and happily maintain and develop the platform. We have invested quite a bit into GraphQL in the last few months and believe it can help us even further with reducing an app’s complexity and reusable code for different build targets.
With the rising popularity of Universal Design Systems and technical implementations thereof, reusable UI components are very near. Today, multiple ways of achieving this are being constantly developed. With react-native-dom the Web can be seen as a build target of a React Native app but this is very experimental. Also, quite important to us, the semantic meaning of HTML elements gets lost. A native View
can be a div
, article
, main
, aside
or almost anything else in HTML.
Another approach are Progressive Web Apps. Progressive Web Apps, or PWA, are regular Web Apps that offer native-like functionality like offline behaviour, push notifications, and access to the device’s hardware. For a next project we’d evaluate whether we could build a Progressive Web App based on the requirements but would definitely head for a shared library again if a PWA was off the table.