DEV Community

FEConf
FEConf

Posted on

React Native and Web Coexistence - Another Approach (Part 1)

React Native and Web Coexistence - Another Approach (Part 1)

This article summarizes the presentation <React Native and Web Coexistence - Another Approach> delivered at FEConf2024. The presentation content will be published in two parts. Part 1 explores the problems encountered when web and React Native communicate and the methods to solve them. Part 2 will cover the actual case studies and the process of achieving Type-Safety and synchronization between web and app. All images inserted in this article are from the presentation materials with the same title, so individual sources are not indicated separately.

'React Native and Web Coexistence - Another Approach'

Seonkyu Kang, In-Edit Developer

Hello. I'm Seonkyu Kang, and I'll be presenting "React Native and Web Coexistence - Another Approach." I'm developing a platform called 'Brandergen' at In-Edit, which connects brands and creators. I'm passionate about open source and constantly think about ways to improve developer experience.

I've developed a library that creates communication interfaces between React Native WebView and web. In this presentation, I'll introduce communication concepts necessary for WebView development based on this library.

You can find documentation for this library at the address below, and I think it will be easier to understand if you follow along while reading it.

Image description)

Transition from Cordova to React Native

Before diving into the main content, let me first introduce what happened while building our service. The existing Brandergen service was developed with Cordova, and we decided to transition it to React Native. The Cordova-written code contained a lot of legacy code, and we wanted to gradually transition to React Native while solving various problems arising from the legacy code.

Cordova

First, let me briefly explain Cordova. Cordova is a tool that packages web app-developed services into apps for distribution. Since our company's existing service was built as a 100% web app based on Vue2, we needed a tool to wrap the web app as an app to release it on app stores. We developed this wrapping part using Cordova and released it on app stores.

Image description

Legacy Code

The image below shows the Lighthouse score of our existing service. It shows quite poor scores, and the reason can be found in the existing legacy code. As mentioned earlier, managing legacy code based on Vue2 led to a situation where we could only write more legacy code.

Image description

This led us to decide to eliminate legacy code and gradually transition to React Native. Eventually, newly created screens would be developed with React Native, while existing Vue.js parts would be wrapped in WebView, resulting in an app where React Native and WebView coexist.

WebView Communication

Through the transition to React Native, we began developing WebView in earnest. When developing with WebView, you encounter communication problems.

Why is WebView communication necessary, and how did I solve the communication problems I experienced? In this article, we'll explore the following three situations:

  1. In-app browser
  2. Native navigation
  3. Shared data

Image description

An in-app browser is a feature that launches a browser in-app from a native app. This feature cannot be used on the web, so we need to borrow the power of the native app. Also, since we were making a gradual transition to React Native, the web needed to be able to navigate to React Native screens. Finally, authentication information is mostly stored in native apps, so the web needs to be able to fetch and use shared data from the native app.

It's also necessary to understand WebView communication. The code below shows the basic communication method provided by React Native. When communicating from web to React Native, you can send strings through the web's ReactNativeWebView's postMessage. The WebView processes the received strings through a prop called onMessage.

Image description

When communicating from React Native to web, you can inject JavaScript through a prop called injectJavascript. There's also a method to extract a reference (ref) and inject JavaScript in the same way.

Image description

Image description

Initially, I proceeded with development using these methods and created communication interfaces.

If I had continued this way, could I have successfully completed the development? This approach brought several problems.

Problems Encountered with WebView Communication

1. onMessage Branching

The first problem is the countless branches in onMessage. As shown in the code below, all event-based logic needs to be handled with branching in onMessage. The code below only handles 3 cases, but in real situations, countless communication scenarios are required, making the code increasingly complex and heavy. This creates maintenance difficulties.

Image description

2. Inefficient Function Redefinition

The second problem is having to declare communication functions on both web and React Native sides as shown below. For example, when an in-app browser feature is added, the web needs to send a message saying "please open in-app browser," and React Native needs to write code to handle this. Therefore, every time a feature is added, leads to development inefficiencies.

Image description

3. Unidirectional Problem

The third problem is the unidirectional issue. When the web sends PostMessage to React Native, React Native cannot return a result value. The biggest problem with this structure is not knowing success or failure. Therefore, the web faces the challenge of writing foolproof code. I considered this unidirectional problem the biggest issue.

Image description

4. Backward Compatibility

The last problem is backward compatibility. Unlike the web, applications don't immediately maintain the latest version even after deployment. The app store needs to review, approve the review, and then actually update to the app store. On the other hand, the web maintains the latest version immediately upon deployment.

What happens when users have past versions of the application and the web calls new native features? Past versions of the application cannot handle code with the latest features, so there will be no response or errors will occur.

Image description

Real Case Study

Let me look at real cases of the problems introduced earlier. The service I was building was making a gradual transition to React Native, and I started improving the product detail screen. However, this created situations where clicking a product on a WebView screen needed to either navigate to a new native screen or to an existing legacy web screen.

In other words, I needed to check the user's app version and implement different page navigations according to the situation. Therefore, I had to write code that guaranteed success, checking the user's app version through user agent and calling events that could definitely succeed.

Image description

To briefly summarize the problems with the existing approach described so far: First, there's a maintenance problem because all events are handled through onMessage. Also, it's somewhat inefficient to write communication functions on both web and app sides whenever features are added. Next, due to the limitations of unidirectional communication, you can't know the success or failure of features, so you must write code that guarantees success. Finally, there are difficulties in determining backward compatibility based on app versions.

Image description

Changing Perspective to Solve Problems

I decided to rethink these problems from the beginning. Looking at the client-server structure that's very familiar to web developers, the client sends requests to the server, and the server receives these requests, processes them, and sends responses back to the client.

In this structure, the client can know appropriate success and failure. I want to introduce a simple interface that borrows this structure.

Image description

Below is tRPC code. First, tRPC is a framework that ensures type safety between server and client and allows building APIs without separate schema definitions.

The left code in the image below is the server, and the right is the client code. The server declares procedures and passes input and result values. The client can write code in a form that can directly use those procedures. You can see that tRPC doesn't have separate communication code.

Image description

Inspired by tRPC, I thought we could create a similar structure. By slightly changing the perspective from the server-client structure, you can see it's not much different. For example, what if we think of React Native as the server and WebView as the client?

Image description

If WebView sends requests to React Native and React Native delivers appropriate responses, we could use tRPC's structure without much difference from the frontend-backend structure.

Usage-Centered Design: Usage

Let me introduce the usage patterns decided after various considerations. In the React Native shown in the image below, native methods are declared in the bridge and return values are sent in getMessage. This structure is called a bridge, and this bridge is injected into createWebView.

On the web, executing the linkBridge function should make the openInAppBrowser contained in this bridge directly usable, and to solve the unidirectional problem, it should return as a Promise structure so you can infer success and failure through then and catch. Also, it should be able to receive and output return values sent from React Native, and executing strange functions like asd that aren't declared in the bridge should generate errors.

Image description

Usage-Centered Design: Initialization

Now that we've designed the usage, it's time to develop the actual functionality. Several concepts were established while developing the functionality.

The first concept is Initialization. When native methods are initially declared, the names of these native methods are injected into the web. Therefore, the web receives the list of native method names.

Image description

Usage-Centered Design: Hydration

The second concept is Hydration. If you've used Next.js or Remix, you'll know the concept of Hydration, and I established this concept inspired by that. Since native method names are exposed to the web during initialization, I enabled automatic generation of communication code based on those names.

In other words, when something like openInAppBrowser is injected, I implemented functionality that automatically creates communication code at runtime like the postMessage below.

Image description

Usage-Centered Design: Event to Promise

The third concept is converting event structure to Promise structure. When the automatically created openInAppBrowser is executed on the web, it sends an event to React Native. openInAppBrowser simultaneously installs an EventEmitter while React Native sends a response event. When openInAppBrowser receives that response event, it resolves the Promise and sends the result value to the web. This structure helps mitigate the limitations of unidirectional communication between the web and React Native.

Image description

Usage-Centered Design: Exception Handling for Non-existent Methods

As mentioned earlier, since communication code is automatically generated, mistakes of using non-existent methods can occur. For example, if a bridge is declared as shown below and ready to use, but you execute a method that doesn't exist in this bridge, runtime errors can occur as shown below.

Image description

Such runtime errors can be gracefully handled using a Proxy. As shown below, I designed it to wrap the original object: returning the actual method if it exists, and returning a no-op function for any non-existent method. Therefore, executing methods that don't exist in the bridge will execute but allow error handling.

Image description

Based on the previous design and feature development, you can see that implementation is possible as shown below.

Native methods are declared in the bridge, and native methods are injected through createWebView. During this process, method names are injected into the web through the initialization process.

When linkBridge is executed on the web, communication code is automatically generated through the Hydration process, and functions that can be used directly like bridge's openInAppBrowser are created. Since it's changed to a Promise structure, you can know success and failure through then and catch, and even executing strange functions like asd can be error-handled through Proxy.

Image description

Backward Compatibility: Checking Available Methods

Next, let me introduce how I solved backward compatibility related problems. Since the web always guarantees the latest version, I approached backward compatibility by checking method availability at runtime on the web, which helps prevent compatibility issues when native methods evolve.

When openInAppBrowser and getMessage are injected through the Initialization process, you can easily create utility functions like below. Therefore, you can determine if it's a currently usable method and execute it if available, or execute alternative code if not.

Image description

Backward Compatibility: throwOnError

Second, I also introduced an option called throwOnError. If you put openInAppBrowser in throwOnError, it's a mechanism that causes the web to fail together if this openInAppBrowser method doesn't exist or fails. By propagating errors to the web, this mechanism allows you to catch and handle them seamlessly using standard Promise error handling.

Image description

Backward Compatibility: onFallback

The last is an option called onFallback. This option is a tool that enables batch processing of bridge errors. I think it can be used more effectively when combined with error tracking tools like Sentry.

Image description

So far, we've explored the problems that arose from web and React Native communication and methods to solve them. In the next article, I'll introduce the processes of solving problems in real situations using these concepts.

Top comments (0)