Hello everyone. Dzien dobry Krakow! I’m very excited to be speaking here about building component libraries for React Native apps
Hello everyone. Dzien dobry Krakow! I’m very excited to be speaking here about building component libraries for React Native apps
My name is Satyajit Sahoo and I am a frontend developer at Callstack. You can follow me @satya164 on Twitter, GitHub and Medium. I am a core contributor to react-native-paper and react-navigation.
When I say component library, I refer to a collection of reusable components. By using a component library, you reduce extra boilerplate in your app, and can get productive faster. It's very common to follow a style guide when designing a component library, resulting in a consistent design across your apps.
Today, I'll be talking about my experience in maintaining component libraries and the various challenges involved. Hopefully the things I've learned will be helpful to you.
Building a library can be very different from building an app. In apps, most of the time we can hardcode certain things. Libraries tend to be more generic, because we don’t know all of the ways the user is going to use the library.
Some of those challenges are cross-platform support, accessibility, good documentation, tooling, and many more.
So let’s dive into it.
A promise of React Native is cross-platform development. Lots of people come to React Native, so they can write a single codebase and use it for multiple platforms.
But cross-platform development is not easy. Apart from the design, there are many other fundamental differences between platforms that we need to address when writing cross platform code.
One of the biggest example is, overflow behaviour. On Android, overflow: hidden is the default, on iOS, overflow: visible is default. This can be very tricky, so the best way to handle this is to always assume that overflow is hidden by default when working on cross platform components.
In recent versions of React Native, you can actually set overflow to visible on Android, but gestures such as touches won’t work in the overflow region.
Another difference is that, on iOS, text elements have an opaque background color by default for improved performance. It works fine on solid backgrounds, but not when you have a background image, for example. You can explicitly set it to transparent to avoid this behaviour when needed.
The other difference is the Touchable components. On iOS, touchables such as buttons tend to change the opacity when pressed, while on Android 5 onwards, there’s a ripple effect. Unfortunately React Native doesn’t provide a single touchable which has appropriate behaviour based on the platform, so you’ll need to build your own component which renders the appropriate touchable.
To avoid repeating this every time, you can use the react-native-platform-touchable library made by our lord and saviour Brent Vatne. It renders a ripple effect on supported Android versions and a opacity effect on iOS and unsupported platforms, such as old Android versions and on web.
In react-native-paper, we have a similar component which renders a ripple effect or a highlight effect based on the platform. We also have a separate touchable implementation for web which renders a ripple effect similar to Android.
Next difference is that, there’s no support for shadows on Android. Instead, there’s an elevation style which maps to the native elevation property. So what is elevation? Elevation refers to the position of the element in z-axis. The higher the element is, the bigger it’s shadow.
So basically, it renders a predefined shadow based on material design guidelines depending on the value you provide. There’s not much to customize. Elevation also affects the position in z-axis similar to z-index, so you have to be careful when using it.
To handle this inconsistency, I like to have a single shadow function which calculates shadow properties for iOS and web, based on the elevation value. Here’s a very rough implementation in the slide. In react-native-paper, we have a more robust implementation which produces a more accurate result and handles animated values as well.
Handling the notch is always annoying when writing a library. In React Native, the notch is handled by SafeAreaView. Older iPhones and many Android phones don’t have a notch, so we have to handle the statusbar height.
A common example is that, we have a header in the app and we want it to appear below the translucent statusbar. But we also want to add a top padding so that it doesn’t overlap any content such as the header title.
Every app can have a different StatusBar/Notch height based on the platform, iOS version, whether translucent statusbar is enabled on Android or not etc. There’s no single cross-platform API to get the statusbar height. There is a SafeAreaView API, but it only supports iOS 11+, and can be troublesome when you’re animating views containing SafeArea.
Currently we have some custom logic in react-native-paper to try and guess the statusbar height. We take into account whether the user is using Expo, Android or iOS, and apply the default heights for those environments. On iOS 11 and above, we use the available SafeAreaView instead. We also have a prop to let the user manually override this value.
This approach is not perfect. The value we guessed might be often wrong, and we are using a constant value even if the StatusBar height can change over time. But this is the best we can do right now.
Fortunately there’s this pull request opened by Janic which adds an API to get the height of the Navigation bar at the bottom and the StatusBar on both Android and iOS. So look forward to much easier way of handling Notches and StatusBar in the future.
Sometimes it’s not possible to have a single implementation for all platforms. In those cases, there are different approaches we can use to provide platform specific implementations. One of them is by android, ios or native extensions in the filename.
Sometimes it’s not possible to support all platforms. But when we’re using the file extension approach, we should always provide a fallback implementation, even if it renders nothing. Why? For example, say 9 out of 10 components in the library work on all platforms, but one component has platform specific implementation. If we don’t provide a fallback for this component, it becomes impossible to use the whole library for that unsupported platform.
Metro looks for specific extensions like ios.js or android.js, then more generic native.js, and then just .js. We can take advantage of this and use the most generic extension as the fallback, and have platform specific implementation in the files with more specific extensions.
The other way is to use the Platform.select API. When using Platform.select, we can use the default key to provide a fallback.
So far we talked about some of the differences between iOS, Android and Web, but there are a lot more other platforms. People are building React Native for more and more platforms. There is React Native for web, windows, TV OS, Mac OS, and many more platforms.
Deep down I know, what we really want is React Native for Samsung Smart fridge, but don’t worry, there actually is React Native for Samsung Smart Fridge. It’s called react-native-tizen-dotnet.
So, the main 3 platforms I try to support are iOS and Android and Web. With awesome tools like Expo starting to have web support by default, it’s much easier to support web in your app.
The last thing I’ll say is, always test on all supported platforms. No matter how confident we are, there’s always be going to be inconsistencies, and it’s more work to fix if we find the bugs late.
Responsive design, it’s a pretty big thing on the web. But we don’t talk about it in React Native often, and it’s ignored in many apps. I see a lot of apps locking their orientation to portrait and it makes me sad.
Orientation is not the only thing we need to keep in mind. There are many other features which won’t work properly if the app is not responsive. For example, split screen mode, where the user can render apps side by side. Another one is free-form window mode, mainly useful for tablet devices and Chromebook. Here the app windows can be freely resized like a desktop app window.
Today in React Native, it’s not easy to build responsive layouts. The available APIs don’t encourage building responsive layouts, and it’s often a magnitudes easier not to make the UI responsive.
But there are some basic rules we can follow to make our layouts responsive, First of all, avoid Dimensions API. The dimensions API provides constants for screen height, width etc. Because these are constants, our layout won’t react to changes in dimensions. Okay, we can address that by using Dimensions.addListener and listening to layout updates, but there is another fundamental flaw with it when building components. The Dimensions API provides layout of the whole screen, no matter how our component is used. A component should render according to its available space, not according to how big the whole screen is. It’s often okay in apps, because we know how the component will be used, but component libraries can be used in many different ways and we need make it more generic.
If you avoid Dimensions and rely on flexbox as much as you can, this becomes a non-issue. But sometimes you do need the layout, for example, to do some calculations or conditional logic., React Native also has an onLayout API that you can pass to a View to get its layout. Just keep in mind that this is asynchronous, and you might need to do something like fading in the content after the layout is available for a better user experience.
I had a similar issue in my tab view library, where I need to know the width of the tabview to render screens side by side. I didn’t want to fade-in the content and wanted to make the wait for the layout invisible to the user. So I did some trickery to achieve this.
On the first render, I only render one screen and position it absolutely. This doesn’t require me to specify any width for the screen. When the layout is available, I remove the absolute position and use flexbox to position them. This is entirely transparent to the user.
As you can see in the GIF, the tabview renders normally and supports changes in its layout, such as orientation. I’m sure you can also come up with more such tricks for your apps.
Accessibility is another important aspect to focus on. Accessibility simply means building to optimise access. We should be inclusive about giving equal access and opportunities to everyone wherever possible. Many people rely on assistive technologies to use apps and websites. If our apps are not accessible, we’re making it impossible for them to use.
Component libraries are building blocks, so it’s crucial to have them accessible by default. If the building blocks aren’t accessible, the app built with them won’t be either. This is why we are taking accessibility seriously with React Native Paper.
Let’s talk about some of the common disabilities.
Visual impairments, this includes blind people, people who can’t see properly, different types of color blindness such as deuteranopia, protanopia, tritanopia etc.
Hearing impairments, this includes people who can’t hear properly, people who have trouble processing language, or even people who speak a different language than you do.
Mobility impairments, this includes people who have limited movement abilities, due to physical, neurological or genetic disorders.
I’ll mostly talk about visual impairments, because that’s what we deal with most of the time when building apps. People with visual impairments often use screen readers to interact with apps. A screen reader lets them know what’s on the screen and allows them to interact with the app via certain gestures.
Most operating systems have built in screen readers, such as TalkBack on Android, VoiceOver on iOS and Mac, Narrator on Windows, Orca on Linux, ChromeVox on ChromeOS, and more. There are also third party free and commercial screen readers.
The best way to know if our components work with screen readers is to try to use them with screen readers. We can use the built-in screen readers in the OS to test our components on various devices. On iOS simulator, we can use the Accessibility Inspector tool provided with XCode.
Accessibility inspector will show all of the attached accessibility related info regarding a particular element. For example, here the inspector is showing that our button has the appropriate accessibility role of button and it’s label is undo.
React native provides several APIs to add accessibility related data. Such as accessibilityLabel, accessibilityHint, accessibilityRole, accessibilityStates and more.
There are many things missing and not much we can do right now, but let’s try our best, it’s honest work.
You can of course send a pull request adding the missing APIs and roles. You’ll be my hero if you did that.
The easiest way to ensure accessibility is to use semantic elements. For example, if you need a switch, use the switch component from react native. You could build the same switch with views, but you’ll be missing a lot of behaviour, and it’s a lot of work to implement them yourself.
If you have media such as images, provide alt tags. For audio, provide subtitles.
If your app is built for the web, make it work with a keyboard.
Make sure the colors in your app have enough contrast and usable for people with color blindness.
We just talked about making our components accessible. This also includes people who use a different language, which gets us to right to left languages, known as RTL in short. What is RTL? When we write english or polish, we write and read them from left to right. But there are many languages which are written and read from right to left.
In a region with a RTL language, books open from the right-hand side. Similarly, most UI elements are mirrored when using a RTL language.
Here is an example screenshot in an Arabic script. Most UI elements are flipped here, the icons are moved to the right, the text is aligned to right instead left etc.
In most cases React Native automatically handles RTL for us when we’re using flexbox. There will be some cases where we might want to handle it manually. React Native provides all the necessary APIs to test and implement these.
The i18nManager forceRTL API changes your app to render in RTL mode so you can see how everything looks when using RTL languages. When you call this API, you’ll need to close your app and open it again.
The i18nManager isRTL API is a boolean that tells us if we’re in RTL mode. We can implement RTL specific UI changes using this API.
This one is every React native developer’s favorite, linking. If a library includes native code, we often need to link it to use it. But unfortunately, it doesn’t work properly with many libraries.
So, if you’re building a library, always test that it works properly with react-native link. It’ll save many developers countless hours of struggle.
Fortunately, thanks to these amazing people, soon we’ll be able to have ability to link libraries without any work on the user’s part.
Documentation is one of the most important parts of a library. Good documentation makes happy users. However, writing and maintaining the documentation can be a lot of work.
I love to automate things that can be automated. With the limited time I have, the less things I have to worry about, the better. I like generating documentation from code because it reduces the amount of work we have to do as maintainers, and there’s way less chance of the documentation getting out of date from the actual code.
It’s true that a lot of generated documentation is really bad. In my opinion, it’s not because they are generated, but because they don’t receive much attention. Even though I’m a fan of documentation generation, I like to write detailed comments for specific props so it’s actually understandable. Some topics require more detailed guides, which I like to put them in their own files.
For React Native Paper, we follow this hybrid approach, where we extract documentation for props from code comments and flow types using react docgen, as well as write guides for broader topics.
Our code comments look kind of like this. There are flow or typescript types with comments that will be extracted to the documentation pages.
There are many tools to help you with writing documentation. Docusaurus lets you write your documentation in markdown files and publish them as a website. Docz lets you write documentation in mdx files with helpers for document generation. At callstack, we have our own tool called component docs, which supports markdown, mdx and even vanilla react components. It also supports documentation generation for react components with react docgen. It generates static HTML pages which we deploy to GitHub pages.
When writing documentation, another important thing is to provide runnable playgrounds. This lets users quickly try out components, and also makes it easier to report issues. For playgrounds running on web, you can configure React Live with react-native-web, or use something like React Native Web player. For React Native Paper, we use Expo Snack which lets users run the components directly on their device.
We also have an expo app as the example app in the repo. We use it to preview components while we work on them.
Each of our code snippets in the usage examples have a “Try on Snack” link which loads that example in a full editor on Snack. It’s pretty easy to implement too, you can pass the full code in a query param to the snack website and it’ll load it in the editor.
Static types are great. They help me by providing autocompletion and checking errors in my code.
This is how I feel when a library doesn’t have type definitions. You should definitely use them in your project.
So, there are 2 popular static type checkers, Flow and TypeScript. Both have their own advantages. I’ll quickly compare both so we know which one is better for building libraries.
Flow is not 1.0 yet. It has breaking changes in each release. It means it’s a lot of work to keep it working every release. Due to this reason, React Native versions are coupled to specific Flow versions.
There is also no good workflow for supporting multiple Flow versions except publishing on flow-typed. This would’ve been okay if there weren’t breaking changes each release, but right now the reality is that it’s not always possible to support multiple Flow versions with a single definition.
Even if you’re willing to publish definitions to flow-typed, it’s a lot of work, and Flow doesn’t support extracting type definitions from your existingcode.
None of this would be a big issue in apps. But when working with libraries, we need to support different kind of projects which will have different versions of Flow.
TypeScript is stable and rarely has breaking changes. The TypeScript CLI can extract type definitions from the source code to publish separately. Even if the library users don’t use TypeScript, their IDE can still provide intellisense if your library has TypeScript definitions.
There are of course many other differences, but these are the main things that stand out from a library development point of view. To me, TypeScript is a clear winner for libraries. We used to use Flow in React Navigation, but due to all the issues we removed it, and we want to migrate to TypeScript soon (pull request welcome by the way).
What’s more? Metro supports compiling TypeScript by default in the generated app, so there’s no reason not to use TypeScript.
Now, let’s talk about the most important part, which is, should you use semicolons or not?
Just kidding, it’s about tabs vs spaces.
All kidding aside, tools like ESLint are very useful to find potential issues and bad patterns in our code. Prettier let’s us focus on the code and forget about the formatting.
For testing, Jest, detox and react-native-testing library are great.
I like to use husky to run some checks such as ESLint when commiting code. I also use commitlint to ensure my commits match the conventional commits specification. It’s important to me, because then I can automatically generate changelogs from my commit history.
Then I use circleci to run all these checks for every commit and pull request. It’s a great feeling seeing those green check marks.
So we have made sure of everything. How do we get this amazing library into the hand of users? It’s time to publish the package to npm.
If we’re using a type checker, we should also publish the type definitions.
When publishing different builds, we can add various keys in package.json to let the bundlers know which one to pick.
There are 2 ways to tell npm what to publish, npmignore field where we list the files we don’t want to publish, and the files field to list the files we want to publish. I personally prefer to use the files key to keep things explicit.
So as you can see, it’s lot of things to keep track of. But you can use this CLI called bob to simplify this process.
If you have a React Native library, all you need to do is install @react-native-community/bob, and run yarn bob init to configure your project.
Publishing can be a lot of work. We have to create a tag, create a release on GitHub, publish a changelog and finally publish the package to npm.
I use a tool called release-it which automates all of these.
You can even use it on the CI to publish a new version automatically on every commit to master. There is another tool called semantic release which is very similar.
Before I finish the talk, I just wanted to say, maintaining a component library can be exhausting. Keep it easy to maintain.
Remember, it’s easy to add a new feature, but hard to remove it. Don’t put everything in your component library. It’s okay to write custom components for specific use cases.