The Holy Grail of UI Libraries
What is the Holy Grail?
Within the Tech world there has always been this insatiable quest for the holy grail of UI libraries. One that works across ALL platforms. Android, iPhone AND Web. Imagine having only one code base to maintain / bugfix / update. Think of the time you could save when testing or doing design reviews.
There have been many solutions over the years. Projects such as Phone Gap (which later became Apache Cordova) and Appcelerator originally tried to solve this problem using webviews (basically launching a browser instance with an App wrapper). This was an OK solution for some situations but had its downsides…
Webviews can be slower. Animations and interactions can seem sluggish compared to native apps.
Webviews used the browser engine baked into the OS, which was often quite a few versions behind the actual browsers.
Webviews didn’t have access to lots of the native phone functionality that native apps did.
The main upside was there was only one codebase to maintain. This eliminated complexity and the need to run 3 separate dev teams. Development costs are cheaper and releases are swifter.
Later down the line, libraries like Facebook’s React Native and more recently Google’s Flutter have arisen, effectively solving the Android / iPhone problem. Using these libraries we can build applications utilising the native functionality of the phone's hardware with all the benefits of a native app using a single language (Javascript with React Native or Dart with Flutter). However, there is just one problem… Until very recently Web was missing from the picture. Sure React Native uses React and there are certain things that can be shared across a normal React project and a React Native project. Eg. functional code like hooks or Design tokens but the actual UI Components are not easily shared as React uses normal HTML elements such as <div> and <p> and React Native uses native app specific elements such as <view> and <text>.
There have been a few attempts to solve the conflicting element issue between native apps and web apps over the years.
Recently Flutter added web functionality but this is a fairly new addition and reports say the package that is produced for the web has a larger file size and a longer boot time than other options.
Back in 2015 Nicolas Gallagher published a library called React Native for Web which was designed to solve the problem with the conflicting elements used in both the web and native apps. It requires that all components use the native app way of doing things and then converts the code to usable elements for the web. There are of course some downsides of doing this which we will go into later. The good news is that this project caught on and big companies such as Twitter adopted the library to build their web apps. React native for web has since matured and been adopted into other build tools. Most recently Expo which is one of the main tools we will use in our example below.
Tools
To set up the Holy Grail of UI Libraries we will use the following tools…
React Native for Web (now built into expo)
Setup
First we need to install the expo CLI and initiate a new expo project.
# Install the command line tools
npm install --global expo-cli
# or
yarn global add expo-cli
# Create a new project
expo init holy-grail
When presented with the following options pick “blank (Typescript)” because who doesn't love typescript.
You will have to wait a little while whilst the CLI installs all the dependencies for you but once done you should see the following…
First navigate to the newly created directory then start the expo server by running…
# navigate to new project
cd holy-grail
# run expo server
yarn start
You should see then see…
If you have never used expo before you will need to head over to the app store and download the Expo Go app. The expo site has links to the app store for both Apple and Android.
Once you have downloaded the app, open up your phone of choice and scan the QR code. If using an iPhone, use the phone's camera to scan the code. If using android you will need to scan the QR using the option in the app.
This should bundle the project and run it on your phone. As long as your phone is on the same network as the computer you should see something like this…
Open up App.tsx and take a look. As stated earlier all elements are written in React Native format.
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
Try changing the text and hitting save to see your app update in real time.
So now we have an instance of a native application running on our phone we need to see if we can get the same running for the web. With Expo it’s as simple as pressing ‘w’ over the terminal window running the Expo server. Alternatively you can run the following to launch directly into web mode…
yarn web
This will open your browser and load up http://localhost:19006. Once loaded you should see the following…
This is the same App that was running on your phone natively now running in the browser. We are halfway there.
Try updating the text again and see both the App and the Web update in real time.
Add Storybook
Now it's time to add Storybook so we can start making some components for our cross platform UI Library.
In the root of the project run…
npx -p @storybook/cli sb init --type react
Notice the --type flag set to react, this is because storybook detects the framework you are using and installs the type it thinks most appropriate. If you simply run npx sb init in an expo project it will detect and try to install Storybook with a type of react-native this means you can't view Storybook on the web and will need to plug your phone into the computer to view the components.
To start Storybook run…
yarn storybook
And you should now be able to launch Storybook on port 6006.
Styling
All styling in react native has to be done in JS. We cannot use native css anywhere in this project. (See the down sides section below for more details). We could make web developers more at home by adding styled-components to the library but to keep things simple for this tutorial we will stick to the react native way of doing things and use Stylesheet.create().
Creating our first Component
When we initiated Storybook you will have seen a few new directories get added and a few files get updated. We will now need to tweak a few of these to meet our needs.
First rename “stories” to “components” then open up “components” and delete all the basic examples created by Storybook.
Secondly we want storybook to look for stories in our newly created “components” directory rather than just the “storybook” directory. To achieve this we need to open up .storybook/main.js and change lines 3 and 4…
// from this...
"stories": [
"../stories/**/*.stories.mdx",
"../stories/**/*.stories.@(js|jsx|ts|tsx)"
],
// to this...
"stories": [
"../components/**/*.stories.mdx",
"../components/**/*.stories.@(js|jsx|ts|tsx)"
],
NB: you may need to restart Storybook to get the changes to take effect without errors.
Our first component will be a button component. As react native has no concept of hover (see gotchas) we will need to also install a little helper called ‘react-native-web-hover’. We can do this by simply running…
yarn add react-native-web-hover
Once the helper is installed create a new directory within ‘components’ called “Button” and add the following files.
Button.tsx
import { Pressable } from 'react-native-web-hover';
import {
Text,
StyleSheet,
GestureResponderEvent,
TouchableOpacity,
} from 'react-native';
interface Props {
onClick?: (e: GestureResponderEvent) => void;
children?: React.ReactNode;
}
const styles = StyleSheet.create({
button: {
color: '#fff',
fontWeight: 'bold',
padding: 10,
backgroundColor: 'red',
textAlign: 'center',
},
});
export const Button: React.FC<Props> = ({ onClick, children }) => {
const onPress = (e: GestureResponderEvent) => {
onClick && onClick(e);
};
return (
<Pressable>
{({ hovered, focused, pressed }) => (
<TouchableOpacity onPress={onPress}>
<Text
style={StyleSheet.flatten([
styles.button,
{ backgroundColor: hovered ? 'darkred' : 'red' },
])}
>
{children}
</Text>
</TouchableOpacity>
)}
</Pressable>
);
};
Button.stories.jsx
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Button as ButtonComponent } from './Button';
export default {
title: 'Example/Button',
component: ButtonComponent,
} as ComponentMeta<typeof ButtonComponent>;
const Template: ComponentStory<typeof ButtonComponent> = () => (
<ButtonComponent>Click Me</ButtonComponent>
);
export const Button = Template.bind({});
We should now see the newly created button component in Storybook.
The code is pretty simple and self explanatory so I won’t go into the details here. To test it also works in the native app we should import it into App.tsx and test it out in expo. You should see the same button in expo web, Android and iPhone.
If you are anything like me you are now doing a little dance excited by the possibilities this opens up. Think of the potential. You should be careful before rushing into anything though. Always do your research before jumping into new tech. I'll list some of the upsides, downsides and gotchas below.
Upsides
The obvious upside of this is having one codebase to serve all native apps and web applications. This reduces complexity as there is a singular source of truth, changes only need to be signed off by a designer in one place and all apps have a consistent look and feel. It reduces costs as less developers and testers are needed and it makes re-theming / re-branding so much easier.
Downsides
There are definitely a few downsides to consider before diving into Expo. Firstly take a look at the HTML that is produced by Expo. If you look closely you will see that our newly created button component is not actually a <button /> element like we might expect, it's a series of divs that looks something like this…
<div
tabindex="0"
class="css-view-1dbjc4n r-cursor-1loqt21 r-touchAction-1otgn73"
>
<div
tabindex="0"
class="css-view-1dbjc4n r-cursor-1loqt21 r-touchAction-1otgn73 r-transitionProperty-1i6wzkk r-userSelect-lrvibr"
style="transition-duration: 0.25s;"
>
<div
dir="auto"
class="css-text-901oao"
style="background-color: rgb(255, 0, 0); color: rgb(255, 255, 255); font-weight: bold; padding: 10px; text-align: center;"
>
Click Me
</div>
</div>
</div>
This is definitely not very semantic. Much of the HTML produced by “react-native-web” is made up of a series of divs and spans rather than the appropriate HTML elements. If we were going to work this way we would have to make sure we were extra careful to describe our components properly using aria roles and properties to cater for users using screen readers and other accessibility tools.
Expo have started to cater for this though with the package @expo/html-elements. After installing the package and using its <Button /> component, expo will work out when it's being served on the web and switch to rendering an actual <button /> element. Currently ‘html-elements’ does not support every html element yet but it's got a whole heap and more are being added all the time.
There is also the limited CSS available in React Native to bear in mind. React native does not support traditional CSS. As a result of this we need to use react natives CSS in JS solution for all components. This comes with its own caveats such as bugs in its flexbox implementation, No :hover or other pseudo elements. No em, rem or % units (it's all in px) and no media queries (although there are other ways of creating responsive designs).
Gotchas
No CSS :hover - Need an extra plugin
Animations - You will most likely have to switch up the way you normally do animations on the web.
Forms - You will have to change the way you think about forms and inputs. Eg. Using native date-pickers or capturing data without actually using HTMLs <form> element.
Next Steps
Add Styled Components (some extra setup required)
Look into also implementing react native Storybook for react native as well.
Publish UI library the web
Setup ‘html-elements’ to render a <button /> element for web using @expo/html-elements
Get media queries working using @expo/match-media and react-responsive
Conclusion
All in all, writing a UI library using Expo is an exciting prospect. It allows true cross platform development all in the same language and a singular source of truth. There are some trade offs but there are also equal benefits. Only you can decide if it's right for you just make sure you weigh up the pros and cons before diving in for yourself.