Code Sharing Between React Native and React Web Apps
Lately I’ve spent my time at work building three apps for the same client: an iOS app, and Android app, and a web app, all powered largely by the same codebase.
I want to walk through some the issues the team of 4 developers has faced: figuring out what to share between projects and what stays inside each project; how to set up an npm package that is consumed by each project; and establishing an effective development workflow.
Deciding what and how to share
The problem of deciding what and how much to share is based on a few factors, including how different your web and mobile apps are from each other, and whether or not you are starting work on these apps at the same time or if you are extracting shared logic from an existing project.
Our domain — online ordering — is a good use case for shared logic. Our Native and Web apps look fairly different, but in our case the core business logic is the same. The logic of adding something to a cart or checking out with a credit card does not differ because we are on native versus the web. The design of our apps varies greatly but the logic is backed by the same rules and constraints.
We have the advantage of starting work on both applications at the same time, meaning that we can make these kinds of decisions with a blank slate. We took the proactive approach of architecting the apps under the assumption that we share all of our reducers, actions, constants, services, and many utility functions.
But how do we implement shared business logic while also allowing our clients to customize these interactions? When a user taps a product on our app, we might want to redirect them to a different screen in the app, whereas this doesn’t really make sense on the web.
As all of our actions live in the shared package, these actions make liberal use of callbacks that each client can implement as they see fit.
For example, here is an action that is exported by our shared library:
From the React Native side, we can redirect the user to the cart screen only if they were able to successfully add a product.
A side benefit of this approach is that testing the shared library becomes much clearer! We are able to write tests that perform assertions on our shared code without worrying about view-related side effects or anything specific to a particular client. Those clients can implement their own tests for that functionality, if necessary.
It also makes our clients fairly dumb, because they do not need to know anything about how to transform their arguments (in this case, a product) into the representation we need for our API requests. None of our clients need to know about marshalling data around and transforming it for the request, because this lives at a shared level.
Actions vs Action Creators
You see the largest benefit out of shared code and logic if instead of dispatching plain actions you dispatch action creators in the form of functions that return objects or thunks (or promises!). Initially, creating a function for every action seems like a bit of boilerplate. After all, if you can trigger a state change with this:
Why would you want to define a function for every action?
The answer becomes apparent once you begin to use this function from either client. Imagine that in our example from above, our business requirements change and we need to pass in an additional another value. Now imagine that this value lives somewhere else in the global state tree. If we are already calling addProduct as a function, we can easily change its implementation:
As you develop your app with functions, action creators become your API — black boxes that all clients can call without the need to understand their underlying behavior.
A note on Reducers
All of our reducers are exported by our shared package, but we do not export a store. This means that each app creates its own store, implements combineReducers and defines its own middleware stack.
At the outset of the project, we wanted to keep open the option for client apps to add client-specific reducers. We do this very sparingly, but it is a use case we want to keep available in the future. Our current implementation of each app calling combineReducers is very explicit, which works in our favor because each app has a different middleware stack it must define, which is itself an explicit process.
Finally, this means that our shared app does have a store, as we must implement a store if we want to test our actions against a real store and not a mock store. Most of our tests do use a real store, rather than a mock store, so in our shared package we use a custom piece of middleware that logs the actions received by the store.
This lets us make assertions against the types of actions received by our store and the resulting values of our actions without mocking our store. If you’d like to do the same, we’ve open sourced this middleware as as npm package named redux-action-logging.
Making the Package
Some React code sharing tutorials assume all apps are living in the same codebase or git repository, meaning all shared code can be accessed purely through file paths.
More realistically, you have two or more projects hosted separately, so an npm package will be the easiest way to share code between them.
This is easy as making a new package and setting it as a dependency inside each of your projects. For the path to the shared project, you can use a git repository rather than pointing to a public package on npm.
If your shared package is hosted privately you will need to set the “private” property in your package.json to true.
Shared Package Organization
In our new package, we define the following folder structure, with lib/index.js as the entry point.
Our src/index.js file exports an object with the combined exports of the other directories. For instance:
Our Babel script transpiles these files one by one from our src/ directory of ES6 files and maintains the same file structure inside our lib/ directory of ES5 files. One benefit here is that we can easily check the transpiled output of a file without having to grep through a massive concatenated file.
In our initial approach, we wanted to write only ES6 and have our client apps deal with the transformation. React Native’s packager had a lot of issues with this approach so we reluctantly included Babel in our shared package to transpile our code for consumption.
After an initial period of dissatisfaction, I’m glad React Native gave us trouble and forced us to transpile the shared repository. If our shared codebase has an issue, it will impact both our native and web apps rather than only one of them. This means we can detect issues earlier and don’t have to worry about differences in our apps’ transformation pipelines leading to weird, hard-to-track bugs.
Local Development Workflow
Now that we have a shared npm package, we need to find a good way to develop locally. Ideally, we want to write new shared code and have that code instantly propagate to each of our two apps. Here’s our file structure:
NPM has a mechanism for this exact purpose, npm link, which creates symlinks between a given package and the dependent parent’s node_modules directory. For instance, npm link would turn the shared-package/lib/ directory inside the node_modules directory of each of our projects into a symlink to shared-package/lib.
Unfortunately, React Native’s packager does not currently support symlinks so we had to build something manually.
We created a script to watch the src/ directory of our shared repository for file changes. After a file change, we trigger our Babel transformations, write to the shared package’s lib/ directory, wait for completion, and then copy the ES5'ed files from shared-package/lib to the node_modules/shared-package/lib directories of our two projects.
This also required us to interact with npm programmatically rather than shelling out to run the compilation script because we need to make sure we are finished with the process before we copy files over.
One note: when a file changes, our solution recompiles the entire project, not only the file that changes. While this isn’t particularly efficient, the full recompile and move script takes about 1 second to run, so we are fine with this approach as it means we do not need to write any custom build scripts targeting certain files.
Here is an annotated version of our script: https://gist.github.com/scottluptowski/1d07805f5ff727fbe95199203ec1e0e7
Building two React apps simultaneously has been a tremendous experience for our team. We are able to move at a very fast pace because we can write our complicated business logic in one place and it leave it to our clients to wire up the connections.
As we get deeper into this project, I anticipate needing to develop a concept of API versions of the shared package. I also expect us to continue to refine our shared abstractions if we determine that both of our client apps are jumping through the same hoops to implement a given piece of shared code. The inverse is also true — should the apps diverge, it may be necessary to remove certain pieces of functionality from the shared app.
Thanks for reading and I hope you learned something!