Development of a corporate library of React components. Cross platform approach

This article tells the story of the successful implementation of the design system in the company of one of the largest DIY retailers. The principles and approaches of cross-platform development of UI components using the React and React Native libraries are described, as well as the solution to the problem of reusing code between projects for different platforms.

First, a few words about how it all began, and why the idea of ​​implementing a system design came up. It all started with a mobile Android application for sellers in stores. The application is built on the React-Native framework. The starting functionality was represented by just a few modules, such as searching for products in the catalog and product card, sales document. By the way, now this is a fairly powerful application that has already largely replaced the functionality of information desks in stores.

Next, web-application projects for logistics department employees, as well as various configurators, were launched.

At this stage, an understanding of the general approaches to the design of these applications, as well as the presence of a fairly large code base, appeared. And it was logical to systematize the other for further reuse.

To systematize UI / UX, it was decided to develop a design system. I will not go into details about what it is. On the Internet, you can find many articles on this topic. For example, on Habré, the work of Andrei Sundiev can be recommended for reading.

Why design system and what are its advantages? The first is a common experience and the feeling of using products. Users get a familiar interface regardless of the application: the buttons look and work the way they are used to, the menu opens in the right place and with the right dynamics, the input fields work in the usual way. The second advantage is the introduction of certain standards and common approaches both from the design side and from the development side. Each new functionality is developed according to already established canons and approaches. From the first days, new employees receive a clear line of work. The next is reusing components and simplifying development. There is no need to “reinvent the wheel” every time. You can build interfaces from ready-made blocks with the expected end result. Well, the main advantage in the first place for the customer is saving money and time.

So what have we done. In fact, we have created not just a component library, but a whole cross-platform framework. The framework is based on a batch scheme. We have 5 core npm packages. It is the core for deploying cross-platform web and Android applications. Packages of modules, utilities and services. And a package of components, which will be discussed later.
Below is the UML diagram of the component package.

image

It includes the components themselves, some of which are independent (elements), and some are connected to each other, as well as the inner core or “sub-core”.

Let us consider in more detail what is included in the “subnucleus”. The first is the visual layer of the system design. Everything here is about the color palette, typography, indentation system, grids, etc. The next block is the services necessary for the components to work, such as: ComponentsConfig (component config), StyleSet (I will discuss this concept in more detail later) and Device (a method for working with the device api). And the third block is all kinds of helpers (resolvers, style generators, etc.).

image

When developing the library, we used an atomic approach to the design of components. It all started with the creation of elementary components or elements. They are elementary “particles” that are independent of each other. The main ones are View, Text, Image, Icon. Next are the more complex components. Each of them uses one or more elements to build its structure. For example, buttons, input fields, selects, etc. The next level is patterns. They are a combination of components for solving any UI problem. For example, an authorization form, a header with parameters and settings, or a product card designed by a designer that can be used in different modules. The last and most difficult and at the same time important level is the so-called behavior. These are ready-to-use modules that implement certain business logic and, possibly, include the necessary set of back-end requests.

image

So, let’s move on to the implementation of the component library. As I mentioned before, we have two target platforms – web and Android (react-native). If on the web these are elements well-known to all web developers like div, span, img, header, etc., in react-native these are the components View, Text, Image, Modal. And the first thing we agreed on is the name of the components. We decided to use a react-native-style system, as firstly, some component base was already implemented in the projects, and secondly, these names are the most universal and understandable for both web and react-native developers. For example, consider the View component. The conditional render component method for the web looks something like this:

render() {
	return(
		
{children} ) }

Those. under the hood, this is nothing more than a div with the necessary props and descendants. In react-native, the structure is very similar, only the View component is used instead of divs:

render() {
	return(
		
			{children}
		
	)
}

The question arises: how to combine this into one component and at the same time split the rendering?

This is where a React pattern called HOC or Higher Order Component comes to the rescue. If you try to draw a UML diagram of this pattern, you get something like the following:

image

Thus, each component consists of a so-called delegate who receives props from the outside and is responsible for the logic common to both platforms, and two platform parts in which methods specific for each platform are already encapsulated and the most important rendering. For example, consider the button delegate code:

export default function buttonDelegate(ReactComponent: ComponentType): ComponentType {
    return class ButtonDelegate extends PureComponent {
        
        // Button common methods

        render() {
           const { onPress, onPressIn, onPressOut } = this.props;
            const delegate = {
                buttonContent: this.buttonContent,
                buttonSize: this.buttonSize,
                iconSize: this.iconSize,
                onClick: onPress,
                onMouseUp: onPressIn,
                onMouseDown: onPressOut,
                onPress: this.onPress,
                textColor: this.textColor,
            };
            return ();
        }
    };
}

The delegate receives as an argument the platform part of the component, implements methods common to both platforms and passes them to the platform part. The platform part of the component itself is as follows:

class Button extends PureComponent {
    
   // Web specific methods

    render() {
        const { delegate: { onPress, buttonContent } } = this.props;
        return (
            
        );
    }
}

export default buttonDelegate(Button);

Here is a render method with all its platform features. The general functionality from the delegate comes in the form of an object through props delegate. An example of the platform part of a button for a react-native implementation:

class Button extends PureComponent {

    // Native specific methods

    render() {
        const { delegate: { onPress, buttonContent } } = this.props;
        return (
            
                
                    {buttonContent(this.spinner, this.iconText)}
                
            
        );
    }
}

export default buttonDelegate(Button);

In this case, the logic is similar, but react-native components are used. In both listings, buttonDelegate is a HOC with common logic.

With this approach in the implementation of components, the question arises of the separation of platform parts during the assembly of the project. It is necessary to make sure that the webpack used by us in projects for web collects only parts of components intended for web, while the metro bundler in react-native should “hook” its platform parts, not paying attention to the component for web.

To solve this problem, they used the built-in metro bundler feature, which allows you to specify the platform file extension prefix. In our case, metro.config.js looks like this:

module.exports = {
    resolver: {
        useWatchman: false,
        platforms: ['native'],
    },
};

Thus, when building the bundle, metro first looks for files with the extension native.js, and then, if it is not in the current directory, it hooks the file with the extension .js. This functionality made it possible to place the platform parts of the components in separate files: the web part is located in the .js file, the react-native part is placed in the file with the .native.js extension.

By the way, webpack has the same functionality using NormalModuleReplacementPlugin.

Another objective of the cross-platform approach was to provide a single mechanism for styling components. In the case of web applications, we chose the sass preprocessor, which ultimately compiles into regular css. Those. for web components, we used the familiar react className developers.

React-native components are styled through inline styles and props style. It was necessary to combine these two approaches, making it possible to use style classes for Android applications. For this purpose, the concept of styleSet was introduced, which is nothing more than an array of strings – class names:

styleSet: Array

At the same time, the same-named StyleSet service was implemented for react-native, which allows registering class names:

export default StyleSet.define({
    'lmui-Button': {
        borderRadius: 6,
    },
    'lmui-Button-buttonSize-md': {
        paddingTop: 4,
        paddingBottom: 4,
        paddingLeft: 12,
        paddingRight: 12,
    },
    'lmui-Button-buttonSize-lg': {
        paddingTop: 8,
        paddingBottom: 8,
        paddingLeft: 16,
        paddingRight: 16,
    },
})

For web components styleSet – an array of names of css classes that are “glued” using the library classnames.

Since the project is cross-platform, it is obvious that with the growth of the code base, the number of external dependencies also increases. Moreover, the dependencies are different for each platform. For example, for web components, such libraries as style-loader, react-dom, classnames, webpack, etc. are needed. For react-native components, a large number of its “native” libraries are used, for example, react-native itself. If a project in which it is supposed to use the component library has only one target platform, then installing all the dependencies on another platform is irrational. To solve this problem, we used the postinstall hook of npm itself, in which a script was installed to install dependencies for the specified platform. The dependencies themselves were registered in the corresponding section of package.json of the package, and the target platform should be indicated in the project package.json as an array.
However, this approach revealed a drawback, which subsequently turned into several problems during assembly in the CI system. The root of the problem was that with package-lock.json, the script specified in postinstall did not install all the registered dependencies.

I had to look for another solution to this problem. The solution was simple. A two-package scheme was applied in which all platform dependencies were placed in the dependencies section of the corresponding platform package. For example, in the case of the web, the package is called components-web, in which there is one single package.json file. It contains all the dependencies for the web platform, as well as the main package with components components. This approach allowed us to maintain the separation of dependencies and preserve the functionality of package-lock.json.

In conclusion, I will give an example of JSX code using our component library:


   
     Sample text
   

This code snippet is cross-platform and works the same in a react application for the web and in an Android application on react-native. If necessary, the same code can be “wound up” under iOS.

Thus, the main task that faced us was solved – the maximum reuse of both design approaches and the code base between various projects.
Please indicate in the comments which questions on this topic were interesting to learn in the next article.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *