Learnings From Forking an Open Source Project
In a recent project for a client, we were asked to fork an existing open source React Native app, restyle to match the client’s branding, as well as add some additional features; all this while still maintaining the existing functionality of the app. While this may sound like a simple enough task, it proved to be much more difficult than we could have foreseen due to the way that the existing application was written, making it very difficult for others without tribal knowledge of how the app works to contribute without breaking features. Like we do with all of our projects, we took time to reflect. We wanted to understand specifically what about this code made it so difficult, and what we would do differently. Here are our key takeaways:
Storing Data in Global State 🚫
A common practice, and one used by this app, is to pull all your data from whatever APIs you need and then put it into global state (like Redux) for easy access within your UI. While this may seem simple on the surface, this type of workflow typically leads to a slew of issues for developers as well as bugs that affect users. There’s a lot more that developers have to think about that could lead to potential issues.
Loading and error states — Every time a request for data is made, developers have to keep in mind that their UI has to handle more than just the happy path. This means that when making a request, they have to think about what it looks like to the user while the request is loading and if the request fails. If all their data is being stored in redux, they have to set up a way to manage these loading and error states for every single request they make.
Data being updated from anywhere — A common issue we have seen is data being changed out from under the user on a screen. For example, the data loads in and the user sees the initial data for a second or two, then another job finishes that changes the data and there’s a flicker to the new data. This is a very poor user experience and usually a very hard bug to fix, since so many different things could potentially be updating the data. You also have to think about race conditions, where you are reliant on one job finishing before another in order to have the proper user experience, but this may not always be the case since server load times can be unreliable.
Keeping local data in sync with the server — Whenever data is updated through a user action within the app, that change has to be reflected correctly within your local state. This usually means an API request is made to update the data on the server. While this request is being made a loading UI is displayed to the user. Then on success the redux state is updated, and finally, on error a dropdown is shown and the redux state is not updated.
While all of these things are possible to implement cleanly, it is very difficult for every developer to have all the context necessary to do this correctly every time. These are all things that could be easily mishandled which in turns leads to a very poor user experience, bugs, and crashes if not done correctly. Here at Echobind we prefer to use packages like Apollo Client or React Query to manage all of these things for us. These packages serve as your data and caching layer, and provide built in loading and error states, polling, refreshing, and many other features that we utilize on a daily basis. While these features are possible to manage yourself using redux, it can very easily go wrong. Even if done correctly, it is difficult for other developers to also do so correctly. In order to ensure the most optimal success you would have to have clearly documented all of the potential flows and hiccups.
Over-Optimization 👎
It’s no secret that one of the biggest counter-arguments to using React Native is that there can be performance detriments due to the need for the bridge between JavaScript and native code. The most common situation where this arises is when there are large lists of hundreds of items updating and causing unnecessary re-renders. There are ways to circumvent these potential issues, either through performance-focused libraries or writing native code directly and importing into your React Native project. While there are certainly cases where this is necessary, and is something that should be kept top of mind for any React Native dev, it is not always necessary. There were a variety of instances in this app where they seemed to be optimizing the code and adding a large amount of complexity for a screen or component that didn’t need it. For example, the main list screen on the app used a package made for performance that required predefined heights of components within its list. The logic written to make this list work well took several thousand lines, across multiple files that was very difficult to follow and make changes to. Simply changing the order of two sections within the list turned out to be an entire day of work. Rather than continue to work within this overcomplicated list and waste valuable time, we decided to rewrite it using React Native’s default SectionList component, which performed just as well for our needs and slimmed down the thousands of lines into about 150 of more readable code. These types of unnecessary abstractions, by bringing in a package that now requires extra context for devs coming into the app, as well as the amount of code necessary to use it effectively, only makes things harder, buggier, and more time consuming if they aren't necessary. These types of performance enhancements should be used very intentionally for use cases that require it rather than by default.
Consistency is Key 👌
It is crucial when working on a project with multiple developers to be on the same page in how you write code so that there is consistency across the codebase. The importance of consistent, readable, well-written code cannot be understated when it comes to the quality and stability of an application. Some examples of key points that most applications should consider:
- Separation of concerns (keeping logic separate from UI, for example)
- Consistent data, service, and mapping layers (where does data come from, how do we keep it formatted consistently?)
- Standardized UI library or styling system (what’s the easiest way to keep a consistent theme?)
- Reusing functions and components (how can we isolate necessary changes to be made in a single place for given logic and UI?)
- Scalability (how can we make it easy to add additional X?)
These types of architecture decisions all serve to make the application easier to contribute to and maintain, saving time and money for everyone as well as providing a more robust application. None of them are easy decisions to make, and require architecture planning, internal conversations on code standards, extensive PR conversations, refactoring, and documentation. All of these things are essential to keeping an application running like a well-oiled machine, and without regular and consistent conversation it will quickly become a legacy monolithic application that is painful to make even the simplest changes within.
Write Once 📱
“Write once, run anywhere”. This is one of the greatest advantages provided by React Native, and allows teams to move much quicker with half the devs by sharing iOS and Android codebases (as well as potentially web, desktop, or even TV). While it is possible to run the exact same React Native code on iOS and Android, it is also possible to separate files out by prefix (i.e. Foo.ios.js
and Foo.android.js
) so that they are run only on the respective platform. There are some edge cases where this can be useful, but is generally avoided due to the duplication of code necessary across files to maintain. The preferred path would be to use the Platform
library within a single file to make selective logical decisions based on the platform, to minimize the surface area of separation between platforms. In this application, we found these separate files used in many cases, as well written in native iOS as well, meaning some components were written 3 different times. From what we found, none of these circumstances required this type of platform specification, and the "performance enhancements" provided by adding the native component as well were unnecessary. All of these changes just added additional logic and complexity that can lead to differences on platforms and additional bugs as any change has to be made in 3 different places.
Documentation 🙏
As stated above, documentation is a key aspect of a well-written application. It is immensely helpful to have documentation of standards for a project to help ensure all the devs on the project are in agreement on how code should be written, and to quickly onboard new devs to how things work. This is extremely important when writing libraries or packages consumed by an application, especially if it is written and deployed in a different codebase. There were instances where libraries within this React Native app had been forked, modified extensively, and then deployed and consumed by this package without updating any of the documentation. This makes usage of this package very difficult, given the only way to understand how it is used, and even more so, what’s possible, is to review the source code directly; which can be time-consuming and difficult.
No two projects are the same, and at Echobind we are continuously evolving our technologies and standards to improve our applications and developer experience. While the technologies may be changing, there are core development principles that drive our decisions and provide a base to build off of for every engineer. These are the underlying themes across all of our projects that provide us the consistency necessary to be efficient, effective engineers.