When we want to build a “cross-platform mobile app” the answer always is React Native but what if you want to build a “cross-platform app” that runs across mobile and browser? Here’s where React Native falls short. react-native-web is trying to bridge this gap to some extent but the primary requirement is to write your code in React Native which gets converted to the web, but that itself has a bunch of downsides and the biggest one being - forcing mobile app developers to understand how browsers work. In this talk, I’ll share how we are building a true cross-platform architecture without using react-native-web for our design system at Razorpay.
The Sorcery of Building a Cross Platform Design System Architecture
AI Generated Video Summary
This Talk discusses the development of a cross-platform design system architecture. It explores different approaches and proposes a unified API that works across web and native platforms. The Talk covers techniques for resolving files and declarations, configuring bundlers, and testing for both web and native platforms. It also highlights the bundling of TypeScript types and handling accessibility for different platforms.
1. Introduction to Cross-Platform Design Systems
Hi, everyone. I'm Kamlesh. I work as a principal product engineer and the design systems and infrastructure tools team, which is part of the platform's team at Razorpay. Today I'm going to talk about the sorcery of building a cross-platform design system architecture. We wanted a design language system that works cross-platform. The first approach was to have individual teams building for each platform.
Hi, everyone. I'm Kamlesh. I work as a principal product engineer and the design systems and infrastructure tools team, which is part of the platform's team at Razorpay.
So, today I'm going to talk about the sorcery of building a cross-platform design system architecture. So, disclaimer before I start, this is all about our experience and how we approach this problem space based on the factors. And it doesn't necessarily mean this is the only way.
So, let me start first with the problem statement. We wanted a design language system that works cross-platform. Now, we started with what other approaches that we have at hand. The first approach was have individual teams building for each platforms. That's natural. You have different teams, different platforms, and you have different teams working on those platforms.
2. Approach to Cross-Platform Design Systems
We explored different approaches, including utilizing the expertise of individual teams for each platform and leveraging the native capabilities offered by each platform. However, none of them met our needs for a unified API that works across web and native. So, we came up with our own approach, aiming for a Nirvana State where developers could implement code once and have it work seamlessly on both web and native platforms. We identified the need for a unified API, a testing center, separate bundling for each platform, and shipping TS types with individual bundles. To demonstrate, we will implement a typographic component that works on both platforms, starting with the web platform.
And then we started to state the pros and cons of each of the approaches. So, the pros for this approach was we have expertise of people for each platform. Let's say a native developer was working on an iOS app, they would have their own set of expertise. And then another web developer was working on the web platform, they'd have their own set of expertise. So, we wanted both. And we could also utilize the native capabilities offered by each platform because there are a lot of capabilities that, let's say sometimes native platforms offer you, but the web platforms don't, or they have exposed it a different way. So, we wanted to utilize these capabilities natively by each of the platforms.
The cons for this approach is multiple teams for building the same thing, right? Like you have multiple people who are kind of solving the same problem over and over again. And then we had redundant code for similar things. The third one was less unification of APIs, right? Because now your APIs would be redundant or they would be created by different teams.
Then there was another approach which was use React Native Web, of course, which is very famous option these days. So, it had pros, which was you could write once and use across web and native, which is what we were looking for. Secondly, it was similar APIs across platforms. The cons were React Native Web to write for web too, right? Now challenges for React Native Web to debug web things. It's like native first and then penetrate with web.
Now, none of the above fit our needs. We were looking for something that has one same API and work across web and native, utilize native capabilities offered by each platform and then we had we wanted app devs to do what they are best at and web devs to do what they are best. So, basically our desire at Nirvana State was what if a dev had to implement below across platforms for them it should be like for us it would have been like if you take this subcode and copy paste it for both web and native, it should just work. That was like you could say our Nirvana State.
So, how did we approach this? So, we listed down what all things we need to tackle. So, the first one was same API that should work across platforms, then we wanted testing center because even though you write once and run on both the platforms we still wanted to test our components on both the platforms individually so that we should not miss any of the bugs on either of the platforms. And then we wanted to bundle each platform separately so that you know, your web bundle is not messed up with react native and vice versa because otherwise it will just break. And we also wanted to ship TS types along with each of these individual bundles.
Yes, so let's see things in action by implementing a typographic component. So, this is the component that we implement and it should work as it is on both the platforms. This is basically and state that we'll be doing during this session. Now, let's start with the first one, same APIs that work across platforms. So, if you think about it, the API for this would look something like, let's say if you want to design or create a typographic component, what would the API look like? It would have ID color, font family, font size, font weight, these are the very basic properties for a typographic component. So, usually what you'll do is you'll start with implementing platform by platform. So, the obvious first thing to start is let's just start implementing it on web.
3. Extracting Common Styles and Exposing Components
We are using style components and rendering style text. However, there is an issue with the code that is being highlighted. The only difference is the imports, which are different for web and native. To make it better, we extract the common styles and create a function to return them. We then refactor the code to replace the common part with the new function. We want to expose a way for consumers to import the design system and choose between a web or native component. This is the responsibility of the design system team. To achieve this, we use bundlers and file extensions. Now, let's talk about how Metro resolves files in React Native.
Right. So, we are using style components. So, we just define the styles and then we just render the style text and return it and then we basically export it. Then we come back to native, we kind of again define the styles and how it will render and now just move forward. But there's something that is going on over here. Right. Let's figure out what's going on.
So, if you see this part of code that is being highlighted and this part of code, both of these are same. The only difference between both of these things is the top thing. Right. Which is basically your imports where you are importing your using rendering a style.dev on web because you are rendering a style.txt on native. Right. So, this is the only difference. This is not what we wanted just to see. This is not what we wanted. Right. So, let's see what we can do to make it better.
So, first we'll extract the styles. We'll just see that okay the styles are common. We'll just create a function that will accept certain arguments and then just return it. These are like basically our styles. Now, let's do a refactor. So, this part which is common in both web and native, we'll just remove it and replace it with our newly created getTextStyles function. Now next we want to expose something below for our consumers. This is the step three. Like if the consumer, the consumer should just say import text from design system. Whether it should be a web component that we want to return or we want to return a native component, that should be the abstraction and that should be handled by the tooling or the platform that we are creating. So, that's basically the responsibility of the design system team.
So, how do we turn this concept into reality? How do we achieve something which is like, you know, you say import and the platform should directly identify? So, we use the power of bundlers, file extensions and bundlers. Now, before we dive into that, I want to talk about how Metro, which is basically a bundler for React Native, resolves files as of today.
4. Resolving Files and Declarations
Metro allows implementing platform-specific code by resolving files based on file extensions. We can use this idea to make it work on the web. By configuring Webpack's resolve extensions, we can define the priority order for file resolution. We rename files to indicate the specific platform and ensure our bundlers can pick the correct file. However, TypeScript may still encounter errors. To solve this, we can define declarations to export the correct file.
So, let's say whenever you say import filename from filename, Metro will first start with looking at the file extension, which is basically, is there a file name which exists with, let's say, .Android.ts. If yes, resolve it. If not, then go back to, then go to ios.ts. If not, then go and see if native.ts exists. If it doesn't, then fall back to .ts. This is something that Metro does right now.
So, it allows you to implement things which are specific to Android or specific to iOS, which is a very basic requirement, and sometimes you need it. What if we steal this idea and steal this idea to make it work on web? Let's see how.
So, Webpack has this config, which says resolve extensions, right? This is an array which defines the priority order for the files to be resolved. Now, what we'll do over here is we want to tell Webpack that you are doing whatever you want to do, like TSS and everything. What we will do is we'll put .web.ts and .web.tsx. So, the priority order is basically the way you define the elements in the array. Now, Webpack works basically whenever you say now import filename from filename, it will basically go and say, okay, filename.web.ts, does it exist? If yes, resolve it. If it doesn't, then go and just pick filename.ts.
Now, after we do this, what we have to do is we want to rename our files so that, you know, our platforms or our bundlers are able to pick what the specific file for that particular platform. So, what we'll do is we'll say text.web.tsx and we'll say text.native.tsx, and then we'll have, of course, an index.tsx, which will just do export text from text. Now, let's see if things are working so far. I'll just switch to VS Code and we'll just say text. So, this is basically a text component that we are basically working on. So, you see, this is text.native, this is text.web, and then we have index. Now, let's see if things work. So, all right. Things are rendering as we wanted them. Let me also show the native part. So, things are working absolutely fine. But if you look at this, there's a tiny error, which says, cannot file module text or its declaration. Now, the thing is, we made our bundlers understand how to resolve these files, but TypeScript still doesn't know. Now, the way to come around or solve this error is what we can do is we can define declarations, and we can just say export text from text.web. And things should work. Sorry, my bad.
5. Configuring Bundlers and Testing
We resolved the TypeScript error by configuring the bundlers and defining our declarations. Now, let's move on to testing. We are using React Testing Library and Jest to test for both web and native platforms. We configure Jest to include the necessary extensions and ignore the irrelevant test files. We create tests for a text component and ensure they work for both web and React Native platforms. Testing is now complete.
We need to say text.t.tsx. And yes, this would work. This error is gone because now TypeScript is happy whenever it says it knows it's okay. Whenever you click on it, it knows, okay, fine, I need to resolve it from here. So TypeScript is happy, our outputs are rendering.
So let's get back. So basically if you see, we had configured the bundlers, but then to make this work, we were basically, we kind of defined our declarations. So coming back to our checklist, we have same API that is working across platform, at least a bare minimum version.
Now let's move to the second item in our checklist, like testing, testing for both the platforms. Now we are using React testing library. So let's set up Jest for testing for web, because if you remember, I was saying that we can need Jest to test for both the platforms that we want to test for web and we also want to test for native. So starting with the web config for Jest, what we are trying to say Jest is that you know, just include web extensions thing and ignore all the native.test files. Similarly, for native, we are doing the reverse. We are saying that ignore all the web.test files and include only the native extensions which are ending in .native files.
Now let's write up some tests. So we created a text component right now. We just write a test for it. So this is just the basic extracted component for testing. Now let's see cross-platform magic. So we will create a file called test.web.test.tsx which will basically use the Jest DOM and render it using the renderer that the React Testing Library provides. And similarly we'll do it for for natives. Now let's see if the test actually works. So test with react. So it's running. Finally so our tests are working for web. Now let's see if this works for React Native as well. All right, so the tests are passing which means things are working fine. The native is doing what native platform is supposed to test and the web is testing for the web platforms. Let's move ahead. So testing is done.
6. Bundling and Configuring Rollup
Now let's bundle each platform separately along with our TypeScript types. We'll use Rollup to bundle our design system for both web and react native. Rollup can bundle react native libraries. The first step is to configure Babel by defining plugins and presets for react native. We'll use an environment variable called framework to export the appropriate Babel config. The second step is configuring Rollup with separate configs for react native and react. We'll define the input and output files for each platform. We'll also add scripts to our package.json to build for react and react native. The environment variable framework will determine which config to use. When the build react script runs, it sets the environment variable to react, which is then used by Rollup to create the web config and bundle the index.web.ts file.
Now let's bundle each platform separately along with our TypeScript types. So what should we use to bundle our design system for both web and react native. We'll use Rollup. But can rollup bundle react native libraries? Yes, it does.
So the first step would be to configure Babel. So this is our Babel config. First we'll define the plugins and the presets for react native. So this config is just an object that we are creating right now. It has a key called react native. Similarly, it has a key called react. Now what we'll do is when we are exporting, the way we export is we read an environment variable called framework, and based on what framework is passed, we'll export that particular Babel config. Just hold on to that environment variable. We'll come back to that.
The second step is configuring Rollup. The way we did for Babel, similarly, we'll do it for Rollup as well. We'll define two configs. First we'll start with react native. We'll define the input as index.ts. And then the output would be index.native.js. Just pay attention to this particular thing. Similarly, for react we'll just say the output file is index.web.js in the config. And then we'll add scripts to our package.json, which we'll just say we'll have two scripts, which is build react, and the second is build react native. So if you see here is the environment variable that we are passing. We say framework is react for react, and framework is react native for react native script. But let's see how all this works. Let me show you or let me help you to visualize how these things would work. So first when the script would run for build react, what it will do is it will first set up the environment variable to react, which will then act as an input to the variable config, which will just understand, okay, you know, I need to export my react config. And then next step would be rollup. So it will just read the environment variable and it will return the react config, basically web config for rollup. And after that, it will just go like once you run this, it will just go and create a bundle, which is index.web.ts.
7. Bundling Types and Accessibility
And similarly, it will do for React Native. Our types are co-located alongside our components, but we are not bundling the types. We need to bundle our types in the same structure as our library bundle. We enhance our config and use a plugin called declarations to generate a bundle of declaration files. We have only one type bundle because we have the same API across platforms. We have achieved the same API, testing setup, and bundling for each platform along with TypeScript types. Now, let's talk about accessibility. On React Native, it's called accessibility role description, while on the web, it's called area role description.
And similarly, it will do for React Native. And what you'll get is React native.ts. So let's build it in action. We'll just go back to our browser and we'll just run the build Yarn build. Let's go do a bunch of things, create everything. And it is creating the builds. Okay, so if you see, we are able to create the bundle which is .native and similarly the .web one, which is basically that we are able to create what we wanted. Let's go back. But wait, what about types? Our types are co-located alongside of our components if you would have seen, right? But we bundle the components into a single bundle, but we are not bundling the types. What do we do? So we need to bundle our types in the same structure as our library bundle, right? But wait, how are we going to do it? Again, roller to the rescue. So remember our old config where we had react-native and react. What we'll do is we'll enhance it, and we're going to use a plugin called declarations. We will do, again, a roller plugin declarations. What it does is it's basically, it will just generate a bundle of your declaration files. So the input is index.d, and it will generate to your build file side by side to our actual builds. So this is what it will look like, the final structure. Let me go back to the code quickly and let me add this bundle. And let me run the build again. Yeah, it's running, it's running, it's running. All right, it's done. So now if you see, we have these types. And if you open it, it has all the declarations that we exported or we wanted. So since it's side by side to your index files, your intelligence, everything would just work. And that's basically what we wanted. So we are one thing to emphasize is we need only one type bundle because we have the same API across platforms, right? Remember that's basically what we started off with. So coming back to our checklist, we have same API, which works across platform. We had testing setup that will test for individual things so that we don't miss on any bug in the while we are trying to achieve while we are trying to achieve cross platform, and then we are able to bundle each for each platform along with the typescript types. But wait, what about accessibility? Let's talk about accessibility. So there are like some native and web accessibility prompts, right. On React natives, it's a communication called accessibility role description versus on web it's called area role description.
8. Handling Accessibility for Different Platforms
We create two maps with aliases and respective values for each platform. We put them into separate files and use a function to determine the prop name based on the build platform. By importing the make accessible function and passing the accessibility prop, we can render the appropriate prop for each platform. This ensures that accessibility is handled correctly.
Then we have accessibility label on React native, but on web you have like area label. So these are like a bunch of differences. So taking the same approach as our components, we'll kind of create two maps, right, which will have like an alias, that's a label required hidden invalid in this in this example, and access like we kind of gave it the respective values for each platform. So these are two maps. And then we put it into two different files, which is accessibility.web.ts and .native.ps. Then we'll throw a function on top of it, which will be resolved or run, which will be used at runtime. And it will just say, okay, if you are actually at build time, and it will just say that, you know, you're building for web or you're building for native based on that it will just do a lookup and give you the respective prop name. Let's see the action. We'll just again, take the typography component that we built. And we'll import our make accessible function, pass it the accessibility prop. And if you see at the bottom, this is what you'll get, right? It will just give you the prop, which for that particular program. So it will just say text roll. You'll just say text roll heading, and it will just do a render area roll on web and just roll on React Native. So, yeah, that's basically what we wanted, right? Your accessibility is also sorted.