DEV Community

FEConf
FEConf

Posted on

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

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

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

In the previous article, we explored the problems encountered when web and React Native communicate and the methods to solve them. In this article, I'll introduce the process of solving actual cases using the content from the previous article, and the Type-Safety and web-app synchronization encountered during this process.

Solving Real Cases

we are now able to more effectively address the problems encountered in real-world cases. When we received the requirement that "clicking a product in WebView should show either a native screen or an existing web screen," we previously had to write code assuming successful execution and manually handle all edge cases, but now we have various tools prepared. Therefore, I thought we could deliberately cause failures and perform failover.

Image description

The image below shows the code for the previous real case. In this bridge, navigate that calls React Native screens from the web is placed in throwOnError. When navigate fails, the web will also throw an error, enabling unified error handling via catch blocks. So when moving to the ProductDetail screen through this bridge navigate, if it's an existing screen, it will navigate well, and if not, it can navigate to the legacy page of the old version through catch.

Image description

Let me summarize how we solved the problems with the existing approach.

The phenomenon where complex logic is concentrated in onMessage can be solved through method-by-method management. Previously, when adding features, we had to write repetitive communication functions on both sides, but by automatically generating communication functions, we only need to maintain them in React Native. Also, the important problem of unidirectional communication was solved by changing to a Promise structure, and finally, regarding backward compatibility issues, while we couldn't solve everything, we were able to mitigate the issue by leveraging utilities that support graceful failover.

Image description

Type Safety

What I'll introduce now is about 'Type Safety', which I considered important while creating a library through this process. Let's briefly look at why Type Safety, often mentioned in the TypeScript ecosystem, is necessary.

Why Type Safety is Needed: Type Mismatch

One reason Type Safety is needed is type mismatch. When frontend and backend projects exist independently, they generally cannot know about each other. When the frontend calls APIs to the backend, it cannot know the types for the API responses, so types must be defined separately.

However, when defining types separately, mistakes occur and naturally type mismatches happen. Likewise, if we consider React Native as the backend and WebView as the frontend, type mismatches are bound to happen.

Image description

Let's look at this type mismatch in code. As shown below, edges has an array of objects composed of node and the corresponding node's id. Through this response, types for responses are defined as shown in the sample code on the right. Since types exist normally, we expect rendering to work without problems.

Image description

But what happens if null comes in the id value? Probably rendering won't work properly and runtime errors will occur as shown in the left code below. When such type errors occur, we first need to handle null exceptions through optional chaining and solve the problem.

Image description

Efforts to Solve Type Mismatch

Going through this process, I thought that types written by humans cannot be 100% trusted. If such type mismatch situations occur repeatedly, TypeScript can lose its purpose. I think the biggest purpose of TypeScript is to discover bugs in advance at the compile stage. When we frequently encounter type mismatches, situations where we ignore types and develop often occur, which makes TypeScript lose its purpose.

However, the TypeScript ecosystem is very large, so there are many efforts to solve these type mismatches. In REST APIs, we can convert Swagger to TypeScript through openapi-generator. In GraphQL, we can convert schemas to TypeScript through graphql-codegen.

Image description

These tools are also sufficiently excellent, but Swagger also involves human touch, which can eventually lead to mistakes like type mismatches. If incorrectly written types in Swagger are converted to TypeScript, this can also lead to type mismatches.

Not Defining Types Directly: Type Inference

To solve this problem, I decided not to define types directly. More precisely, I decided to actively utilize type inference. Let me look at the type inference concept I applied for Type Safety while creating the library through simple examples.

typeof

When declaring native methods in the bridge, Instead of manually defining interfaces, I allowed the bridge to infer types using typeof. Like this, with the compiler's help, we can infer all types, and if we send this well to the web, the types received on the web can be reflected as normal code intended by the native side.

Image description

keyof

Next, we can improve developer experience by utilizing simple keywords like keyof. As shown in hasMethod in the image below, types can be inferred through keyof. With keyof's help, we can determine and use whether it's an available type.

Image description

generic

What I think is most important in type inference is generic. The bridge function below returns functions called subscribe and getState. And I declared a generic object as the bridge type. Therefore, if a value like 1234 is passed to this bridge, it will cause a type error because it's not an object. On the other hand, if an object is entered here, all types can be completed.

Image description

The most important part of the previous content is completing types through input. As shown below, when using Tanstack Query's useQuery, if you receive a return from the query function, you can complete data based on the return value. It looks like simple code, but internally it's a situation where all types are completed through input via type inference processes.

Image description

The sentence in the image below is what Tanstack Query's maintainer said. Looking at this, if you utilize type inference well, it looks like you're using JavaScript when just looking at the code, but actually you can use it with all types being safe. This is because although useQuery doesn't have a separate interface, all types are completed based on input.

Image description

Diving deep into type inference, type definitions also look very complex and difficult to maintain. However, I greatly empathized with his words that these things are the library's responsibility, not the user's responsibility.

The final result of type inference is as follows. Native methods are declared in the bridge, and corresponding types are exported through typeof. Then, when this type is put as a generic in linkBridge, all types are completed based on this in the bridge.

Image description

Therefore, this bridge can infer available methods like openInAppBrowser, and you can see that available methods are recommended.

If we apply this a bit more, integration with React Navigation is also possible. As shown in the image below, all navigable screens are defined in React Native's StackRootParams. When using the bridge's navigate on the web, all previously defined names and parameters can be inferred.

In other words, the screen list defined in React Native can be used on the web without additional type definitions. This experience greatly improves developer experience and can eliminate situations where developers make typos. This leads to the effect of reducing mistakes about screens to navigate to on the web.

Image description

Synchronization Between React Native and Web

Getting Authentication Information from Native App in WebView

Having implemented type inference well in the library, I deployed the first version. I thought everything would be perfect, but I encountered another problem. it turned out to be a state synchronization issue related to authentication tokens.

One of the reasons why communication between web and app is necessary is to transmit and use authentication information. In the current structure, we first declare getToken in the bridge to return tokens, and on the web, we can extract and use values with this getToken.

However, what happens if this token expires in the native app and the token changes? React Native would have the latest token, but the web would have an expired token, creating a bad situation.

Image description

Separating Web Core Logic for Integration: Shared State

To solve this situation, I thought state synchronization between React Native and web was necessary. The concept I created based on this thinking is Shared State.

Since this concept revolves around state management, it needed to be easily integrable with modern frameworks. Therefore, I started by separating from the web core and began making state-related libraries starting from web core logic.

Image description

Shared State was also designed with a usage-centered approach. This bridge was originally in a state where only native methods could be declared. Therefore, it could only receive Promise functions, but to store values like tokens, I made it possible to input Primitive types like null or strings.

Looking at the React Native declaration part in the image above, get and set are exposed so current state and values can also be set, which resembles Zustand a lot. Since it's a library that manages state, I developed it with much inspiration from Zustand.

On the web, in addition to exposing existing React Native methods, the store is also exposed from the bridge. This store supports subscriptions, allowing the web to react to state changes from React Native. I implemented it so synchronization is possible on the web this way.

Integration with React

The previous example is in vanilla JavaScript. Thanks to this, integration with React is easily possible. React 18 introduced the useSyncExternalStore hook, which enables seamless integration of external stores into React's rendering flow. I was able to extend this into a React state library called webview-bridge/react by wrapping this hook.

If you put the store in useBridge, you can get and use tokens from state. This token exists on the web but stays synchronized with React Native’s state in real time.

Image description

Final Usage: Shared State

Now let's look at the final usage. In React Native below, count is declared as 0 and increased by 1 through increase. Since React Native is also React, if you put appBridge in useBridge, you can use the state called count and the method called increase.

On the web, the bridge is declared through linkBridge and AppBridge, and through this bridge's store and useBridge, you can extract and use count and increase from native core logic. This allows the web to consume and update shared state in real-time, fully synchronized with React Native.

Image description

Final Usage: Native Method

Also, as shown in the image below, the bridge puts input into greeting and returns a return value called msg. Since the bridge uses generics to enforce type safety, calling undefined methods will immediately raise type errors, even when invoked externally. Also, when input is wrong, type errors occur, and when input is received normally, you can see that types for response values are displayed correctly.

Image description

Conclusion: Lessons Learned from Creating the Library

Eventually, I successfully completed developing a type-safe WebView communication library, reducing manual type definitions and utilizing inference as much as possible so all types are completed based on input. Building this library offered valuable insights and lessons across both design and implementation.

Believing that great developer experience stems from usage patterns, I prioritized designing intuitive usage scenarios before implementing features. As a result, This approach led to a clean abstraction and outcomes that aligned well with practical development needs. Also, my library resembles tRPC and Zustand a lot. Through development, I could use various libraries, which itself provided much learning. Furthermore, I could have the valuable experience of directly applying learned concepts to development.

Finally, since I developed starting from web core logic from the beginning, integration with other modern React frameworks was easily possible. Had I built the state library using Vue.js, cross-framework integration would have been significantly more difficult.
Through this, I could also learn what scalable architecture is.

Image description

The library introduced today is open-source and published under the name webview-bridge.
It includes many additional features not covered in this article. so if you're interested, I recommend visiting the address below to learn more details and check related documentation. If you like it, please consider giving it a star on GitHub. Thank you.

Image description

Top comments (0)