How to build a Web App. Part I: Developing on React Native Web

Ilya Mazhugin, mobile developer
Ilya Mazhugin, mobile developer
Sep 16, 2024
12 minutes
Contents
Hi, folks! Unexpectedly, dev.family team is back with a new experiment. This time, we want to share with you our experience of creating an application using React Native for iOS, Android, web, and Telegram.
React Native has long been used to build applications for the web. And Meta, Twitter (X), and Flipkart are examples of companies using this approach. However, for our case study, it is important to understand the context, that other developers may face. The client already had an Android and iOS app on React Native, and they wanted another version of their product in Telegram Web App format. We had previously worked on a similar project with another app, but we had never released it. This experience served as the basis for our development work on this project.
A quick reminder: the Telegram Web App is a web application that runs in its own WebView. You can build it with React and share styles and navigation with Tamagui. However, mobile apps are already built entirely in React Native. To avoid writing code from scratch, we have decided to use the react-native-web.
Find out how web applications in Messenger can help your business collect leads and test new features

Installing the react-native-web

The documentation describes React Native for Web as a compatibility layer between React DOM and React Native that can be used in both new and existing web and multi-platform applications.
In simple terms, it's a library that lets you run React Native code as a web app. Learn more here.
For technical reasons, we cannot show you the application that inspired this experiment. Instead, in this article, we will write a simple clicker game where we will explore different styles, use Haptic, collect profile data, and use a theme. Second, our version of the code may differ from the documentation, as we encountered some problems trying to follow it exactly.
To get started, you'll need a react-native app. In the directory where you want to create your app, run the following command:

npx react-native init react-native-web-example
  • We also decided to remove yarn and use pnpm (this is a personal choice and you can use any other package manager).
For more information about different package managers, read our article
But, unfortunately, react-native cannot immediately use pnpm as a package manager. If you want to use pnpm, you will need to follow these steps:
  • Run the command git clean -xfd
  • Delete packageManager from package.json
  • Install the following packages – (@react-native-community/cli-platform-android, jsc-android, @react-native/gradle-plugin)
  • Then run pnpm install pnpm install cd ios && pod install && cd ..
  • Launch pnpm
To render the web application, we need an index.html file. Let's create it at the very beginning. Put index.html in the root of the project and add the following code:
index.html

<!DOCTYPE html>
<html>
<head>
 <meta charset="UTF-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 <meta http-equiv="X-UA-Compatible" content="ie=edge" />
 <title>Test React Native Web</title>
 <style>
   html, body { height: 100%; }
   /* These styles disable body scrolling if you are using <ScrollView> */
   body { overflow: hidden; }
   /* These styles make the root element full-height */
   #app-root {
     display: flex;
     flex: 1 1 100%;
     height: 100vh;
   }
   input {
     outline: none;
   }
 </style>
</head>
<body>
<div id="app-root">
</div>
<script src="./index.web.js" type="module"></script>
</body>
</html>
We also need index.web.js (you can see it from the script tag). Create it in the root of the project at index.js level and put the following code there:
index.js

import { AppRegistry } from "react-native";
import name from "./app.json";
import App from "./App";
import { enableExperimentalWebImplementation } from "react-native-gesture-handler";


enableExperimentalWebImplementation(true);


AppRegistry.registerComponent(name, () => App);


AppRegistry.runApplication(name, {
 initialProps: {},
 rootTag: document.getElementById("app-root"),
});
Essentially the same thing happens here as in index.js, except that in addition to registerComponent we also find our div с id=”app-root” and render the application in it.
What about enableExperimentalWebImplementation(true) – it's not a required part of the code. However, during the development process we ran into problems when using 'react-native-gesture-handler'. This addition helped us.
Next, you'll need a builder, as Metro won't help in this case. There is a sample webpack configuration on the react-native-web page. You can also get it by installing react-native-reanimated (we install it on all projects with React Native code). But that didn't work for us. We used Vite and a plugin for react-native-web – vite-plugin-react-native-web – as a builder.
Next, create vite.config.js in the root of the project and add the following piece of code:

// vite.config.js
import reactNativeWeb from "vite-plugin-react-native-web";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import commonjs from "vite-plugin-commonjs";


export default defineConfig({
 commonjsOptions: { transformMixedEsModules: true },
 plugins: [
   reactNativeWeb(),
   react({
     babel: {
       plugins: [
         "react-native-reanimated/plugin",
         "@babel/plugin-proposal-class-properties",
         "@babel/plugin-proposal-export-namespace-from",
       ],
     },
   }),
   commonjs(),
 ],
});
I forgot to mention that the following packages need to be installed before this: vite, @vitejs/plugin-react , vite-plugin-commonjs, vite-plugin-react-native-web, babel-plugin-react-native-web.
If you also want to use react-native-reanimated, you need to add these packages as well: react-native-reanimated, @babel/plugin-proposal-export-namespace-from.
Your babel.config.js will look like this.
babel.config.js

module.exports = {
 presets: ["module:@react-native/babel-preset"],
 plugins: [
   "@babel/plugin-proposal-export-namespace-from",
   "react-native-reanimated/plugin",
 ],
};
We used babel.config.js from a pure React Native project on version 0.74.5 with additional plugins. If you are working with a different version of React Native, look only at the plugins.
Next, add the following commands to the scripts inside package.json
package.json

{
...
"scripts": {
	...
"web:dev": "vite dev",
 "web:build": "vite build",
 "web:preview": "vite build && vite preview"
...
}
...
}
Now we run our app and check that everything works as it should. In our App.tsx we have only text so far. So we get the following result:
We also test the app on iOS and Android to make sure everything works. And then we move on to writing our app.

Writing an app

We already have the basis for the app, but it's quite simple. We want to bring something more interesting to Telegram. Because the messenger has a lot of possibilities and functions to interact with the client. But you need access to them.
According to the documentation, this can be done via the global object window and then window.Telegram.WebApp. Unlike React.js applications, in React Native we don't have such an object (window). And when we try to access it, Typescript throws an error.
But when using react-native-web for a web application, access to the window is provided. The next part is not very nice, but for more convenience we need to specify the types and declare the global object window manually. Create global.d.ts in the root of the project and write the following:
  • Let's start by adding a script to the head tag to connect our mini-app to the Telegram client
index.html

<head>
<!-- paste here -->
 <script src="https://telegram.org/js/telegram-web-app.js"></script>
 ...
</head>
... 
global.d.ts

type TelegramTheme = {
 bg_color: string;
 text_color: string;
 hint_color: string;
 link_color: string;
 button_color: string;
 button_text_color: string;
 secondary_bg_color: string;
 header_bg_color: string;
 accent_text_color: string;
 section_bg_color: string;
 section_header_text_color: string;
 section_separator_color: string;
 subtitle_text_color: string;
 destructive_text_color: string;
};


type WebAppUser = {
 id: number;
 is_bot: boolean;
 first_name: string;
 last_name: string;
 username: string;
 is_premium: boolean;
 photo_url: string;
};


type WebappData = {
 user: WebAppUser;
};


type TelegramHapticFeedback = {
 impactOccurred: (
   style: "light" | "medium" | "rigid" | "heavy" | "soft",
 ) => void;
 notificationOccurred: (type: "error" | "success" | "warning") => void;
};


type TelegramWebapp = {
 initData: string;
 initDataUnsafe: WebappData;
 version: string;
 platform: string;
 themeParams: TelegramTheme;
 headerColor: string;
 backgroundColor: string;
 expand: () => void;
 close: () => void;
 HapticFeedback: TelegramHapticFeedback;
};


type Window = {
 Telegram?: {
   WebApp: TelegramWebapp;
 };
};


declare var window: Window;
In the file we have specified the necessary data types that we will get from Telegram.WebApp and declared a global window.
But remember, we are also writing a mobile application. That's why we won't use the window object directly, to avoid making mistakes. Instead, we'll create a global TelegramConfig object where we'll write all the data from Telegram. But for the mobile part, we'll create a MockConfig where we'll enter everything ourselves. These will be static as we will not receive any data from Telegram.
Create an src/config.ts file and write:
config.ts

import { Platform } from "react-native";


export const MockConfig = {
 themeParams: {
   bg_color: "#000",
   secondary_bg_color: "#1f1f1f",
   section_bg_color: "#000",
   section_separator_color: "#8b8b8b",
   header_bg_color: "#2c2c2c",
   text_color: "#fff",
   hint_color: "#949494",
   link_color: "",
   button_color: "#358ffe",
   button_text_color: "",
   accent_text_color: "#0f75f1",
   section_header_text_color: "",
   subtitle_text_color: "",
   destructive_text_color: "",
 },
 initDataUnsafe: {
   user: {
     username: "MockUser",
     is_premium: false,
     photo_url: "",
     first_name: "",
     last_name: "",
     id: 0,
   },
 },
} as TelegramWebapp;


export const config = () => {
 if (Platform.OS !== "web") {
   return MockConfig;
 }


 if (window.Telegram?.WebApp.initData) {
   return window.Telegram?.WebApp;
 } else {
   return MockConfig;
 }
};
Here we create a MockConfig for our mobile or web app in case there is no data from the Telegram client. Next, a configuration function that will return the data from Telegram if it is available, otherwise -} MockConfig. We will use this to get the data.
Now let's write a little clicker using the theme settings and user data from our config/tg.
Let's be clear: we are not trying to create a complex application in this article. It is much more important to show you how to use the Telegram client and its functions/options without any problems.
First, let's run the following command to place the navigation libraries inside the application. This is not necessary for this example, but we have done it for you anyway 🫢

pnpm add @react-navigation/native-stack @react-navigation/native react-native-screens
We'll also add animation packages:

pnpm add react-native-reanimated react-native-gesture-handler
Let's make the following settings to connect the animations. Go to babel.config.js:
babel.config.js

module.exports = {
 presets: ["module:@react-native/babel-preset"],
// add plugins here 
 plugins: [
   "@babel/plugin-proposal-export-namespace-from",
   "react-native-reanimated/plugin",
 ],
};
Install pods for iOS:

cd ios && pod install && cd ..
Next, create the following folders: src, src/components, src/screens and the files in them:
  • src/RootNavigator.tsx
  • src/screens/HomeScreen.tsx
  • src/utils.ts
  • src/components/index.ts
  • src/components/Coin.tsx
  • src/components/Header.tsx
  • src/components/Progress.tsx
  • src/components/Screen.tsx
Inside the src folder we get a structure like this:
β€” components
  • Screen.tsx
  • Coin.tsx
  • Progress.tsx
  • index.ts
  • Header.tsx
β€” screens
  • HomeScreen.tsx
β€” utils.ts
β€” App.tsx
β€” RootNavigator.tsx
So we have created the files for the components. Now let's start filling them in and creating the components themselves. Start with the header:
Go to src/components/Header.tsx
src/components/Header.tsx

import { Image, StyleSheet, Text, View } from "react-native";
import { config } from "../../config";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import React from "react";


type HeaderProps = {
 amount: number;
};
export const Header: React.FC<HeaderProps> = ({ amount }) => {
 const insets = useSafeAreaInsets();
 const paddingTop = Math.max(20, insets.top);


 const { username, photo_url } = config().initDataUnsafe.user;


 return (
   <View style={[styles.header, { paddingTop }]}>
     <View style={styles.amountRow}>
       <Image
         source={require("../../assets/icons/coin.png")}
         style={{ height: 40, width: 40 }}
       />
       <Text style={styles.text}>{amount}</Text>
     </View>
     <View style={styles.userInfo}>
       <Text style={styles.username}>@{username}</Text>
       {photo_url ? (
         <Image
           style={[styles.image, { backgroundColor: "transparent" }]}
           source={{
             uri: photo_url,
           }}></Image>
       ) : (
         <View style={styles.image}>
           <Image
             style={styles.icon}
             source={require("../../assets/icons/profile-placeholder.png")}
           />
         </View>
       )}
     </View>
   </View>
 );
};


const styles = StyleSheet.create({
 header: {
   backgroundColor: config().themeParams?.header_bg_color,
   paddingHorizontal: 20,
   flexDirection: "row",
   alignItems: "center",
   paddingBottom: 20,
   justifyContent: "space-between",
 },
 amountRow: {
   flexDirection: "row",
   alignItems: "center",
 },
 text: {
   fontSize: 24,
   fontWeight: "600",
   color: config().themeParams?.text_color,
 },
 userInfo: {
   flexDirection: "row",
   alignItems: "center",
   gap: 20,
 },
 username: {
   color: config().themeParams.accent_text_color,
   fontSize: 18,
 },
 image: {
   backgroundColor: config().themeParams.button_color,
   height: 50,
   width: 50,
   justifyContent: "center",
   alignItems: "center",
   borderRadius: 50,
 },
 icon: {
   height: 30,
   width: 30,
   tintColor: config().themeParams.text_color,
 },
});
As you can see from the example above, we use the config().themeParams method to get the theme settings for the header colours, as well as the user information, such as the username and photo_url. However, as you can read in the documentation, photo_url may not exist, so let's add a check that it doesn't exist (in this case we output a stub). Create an assets/icons folder in the root of the project, where we will store our images. In this application we only need two of them: a stub for the user's photo and an image of the coin itself, which we will click on.
Now that we've finished with the header, let's move on to the next component: the coin itself. Just inserting an image and clicking on it is not interesting. So we will add some animations: the animation of the digit appearing and disappearing, which will be quite simple, and the animation of the coin rotating.
src/components/Coin.tsx

import React, { useState } from "react";
import {
 Dimensions,
 GestureResponderEvent,
 Image,
 Platform,
 Pressable,
 StyleSheet,
 Text,
 View,
} from "react-native";
import Animated, {
 SlideOutUp,
 useAnimatedStyle,
 useSharedValue,
 withTiming,
} from "react-native-reanimated";
import { generateUuid } from "../utils";
import { config } from "../../config";
import { useHaptics } from "../useHaptics";
import { ImpactFeedbackStyle } from "expo-haptics";


//animated component to have ability use animated style from Reanimated package
const AnimatedButton = Animated.createAnimatedComponent(Pressable);


const sensitivity = Platform.OS == "web" ? 0.1 : 0.2;


const animationConfig = {
 duration: 100,
};


/**
* @prop onClick - what happened on click the coin
* @prop disabled - when coin can be clicked or not
*/
type CoinProps = {
 onClick: () => void;
 disabled?: boolean;
};


export const Coin: React.FC<CoinProps> = ({ disabled, onClick }) => {
 const [number, setNumber] = useState<
   { id: string; x: number; y: number } | undefined
 >(undefined);
 const [showNumber, setShowNumber] = useState(false);


 const width = Dimensions.get("window").width - 50;
 //setting coin size based on window and check web compatibility
 const size = width > 1000 ? 1000 : width;
 const center = size / 2;


 //shared values to use in coin animation
 const rotateX = useSharedValue(0);
 const rotateY = useSharedValue(0);


 const { impactOccurred } = useHaptics();


 const handlePressIn = async (e: GestureResponderEvent) => {
   await impactOccurred(ImpactFeedbackStyle.Light);


   const { locationX, locationY } = e.nativeEvent;


   //getting rotate amount by x axis
   const deltaX = locationX - center;
   //getting rotate amount by y axis
   const deltaY = locationY - center;


   if (Platform.OS === "web") {
     rotateY.value = deltaX * sensitivity;
     rotateX.value = -deltaY * sensitivity;
   } else {
     rotateY.value = withTiming(deltaX * sensitivity, animationConfig);
     rotateX.value = withTiming(-deltaY * sensitivity, animationConfig);
   }


   //set number position && unique id to have no problems with keys
   setNumber({ id: generateUuid(), x: locationX, y: locationY });
 };


 const handlePressOut = (e: GestureResponderEvent) => {
   setShowNumber(true);
   if (Platform.OS === "web") {
     rotateX.value = 0;
     rotateY.value = 0;
   } else {
     rotateX.value = withTiming(0, animationConfig);
     rotateY.value = withTiming(0, animationConfig);
   }


   onClick();


   // use timeout to not remove element on render start
   setTimeout(() => {
     //set values undefined to launch exiting animation
     setNumber(undefined);
     setShowNumber(false);
   }, 10);
 };


 //style to define coin rotation
 const rotateStyle = useAnimatedStyle(
   () => ({
     position: "relative",
     transform: [
       {
         rotateY: `${rotateY.value}deg`,
       },
       {
         rotateX: `${rotateX.value}deg`,
       },
     ],
   }),
   [rotateX, rotateY],
 );


 return (
   <View style={styles.container}>
     <AnimatedButton
       style={[rotateStyle]}
       disabled={disabled}
       onPressIn={handlePressIn}
       onPressOut={handlePressOut}>
       <Image
         source={require("../../assets/icons/coin.png")}
         style={{ height: size, width: size }}></Image>
     </AnimatedButton>
     {!!number && showNumber && (
       <Animated.View
         exiting={SlideOutUp.duration(500)}
         key={number.id}
         style={{
           position: "absolute",
           top: number.y,
           left: number.x,
           zIndex: 1000,
         }}>
         <Text style={[styles.text]}>+1</Text>
       </Animated.View>
     )}
   </View>
 );
};


const styles = StyleSheet.create({
 container: {
   position: "relative",
 },
 text: {
   fontSize: 26,
   fontWeight: "600",
   //getting text color from Telegram client
   color: config().themeParams?.hint_color,
 },
});
All animations play twice – when you press the coin and when you let go. Let's analyse this part of the code in more detail.
Create rotateX, rotateY (SharedValue) and rotateStyle(AnimatedStyle). In rotateStyle we look at the changes to our SharedValue and mutate it to match the position of the click on our coin. Next, we pass the animated style itself to AnimatedButton, which we got after using the createAnimatedComponent function and its Pressable argument. Depending on rotateX and rotateY, the coin will tilt to one side or the other.
When the button is pressed, the rotation of the X and Y axis will change. After that, we will get the coordinates of where the press occurred. We will subtract the center of our element from these coordinates to find the delta. Then, we will multiply the delta by a sensitivity value (which can be between 0 and 1) to get the angle of tilt for the X and Y axes. Finally, we will write the values for X and Y presses into a number to use for displaying the flying digit.
All of the actions listed above are necessary for writing the logic when the button is pressed. However, our code should also cover what happens when the button is released. The first step we take is to set the animated value back to 0 so that the coin returns to its original position. It's important to note that we have different conditions for different platforms (both for when the button is clicked and when it is released). Additionally, React Native Reanimated can have problems with animations on the web, so in some cases, they may need to be rerendered. This requires additional validation, because we use withTiming component (which ensures that the value doesn't change at the current moment, but rather at the time specified in the animationConfig).
Next we call the onClick method, which we pass to the props to perform actions when clicked. In setTimeout (necessary for the element to appear in time with a number) we remove the value of number and showNumber to trigger our animation when the element leaves the DOM tree.
Since we've already touched on the animation of the figure, we'll use a simple Animated.View and its props – exiting to animate the exit of the renderer from the Reanimated library. When the figure exits we now have an animation that shows the coin going up. We also pass the x and y of the number into the styles to place it where the button is clicked.
Now let's move on to Progress.tsx
src/components/Progress.tsx

import React, { useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { config } from "../../config";

type ProgressProps = {
 max?: number;
 amount: number;
};

export const Progress: React.FC<ProgressProps> = ({ max = 3500, amount }) => {
 const [width, setWidth] = useState(0);

 return (
   <View
     style={styles.container}
     onLayout={e => setWidth(e.nativeEvent.layout.width)}>
     <Text style={[styles.text, { width }]}>
       {amount} / {max}
     </Text>
     <View style={[styles.progress, { width: (amount / max) * width }]}>
       <Text style={[styles.text, styles.progressText, { width }]}>
         {amount} / {max}
       </Text>
     </View>
   </View>
 ) ;
};

const styles = StyleSheet.create({
 container: {
   height: 70,
   borderColor: config().themeParams.accent_text_color,
   backgroundColor: config().themeParams.section_bg_color,
   borderWidth: 2,
   borderRadius: 70,
   overflow: "hidden",
   position: "relative",
   justifyContent: "center",
 },
 progress: {
   height: "100%",
   backgroundColor: config().themeParams.accent_text_color,
   width: 200,
   borderRadius: 20,
   position: "absolute",
   justifyContent: "center",
   overflow: "hidden",
 },
 text: {
   fontWeight: "700",
   fontSize: 24,
   color: config().themeParams.accent_text_color,
   textAlign: "center",
 },
 progressText: {
   textAlign: "center",
   color: config().themeParams.text_color,
 },
});
There is nothing complicated about this part. We simply pass the max value and the current value (amount) through the props. As the amount increases, so does the progress value. We also use colours from our config, which can be taken either from the parameters or from the user's Telegram theme settings.
Now let's create a simple Screen.
src/components/Screen.tsx

import React from "react";
import { StyleSheet, View, ViewProps } from "react-native";
import { config } from "../../config";


const styles = StyleSheet.create({
 screen: {
   flex: 1,
   backgroundColor: config().themeParams?.secondary_bg_color,
 },
});


export const Screen: React.FC = ({ children, style }) => {
 return {children};
};
It all comes together in our HomeScreen:
src/screens/HomeScreen.tsx

import { StatusBar, StyleSheet, View } from "react-native";
import { Coin, Header, Progress, Screen } from "../components";
import { config } from "../../config";
import { useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";

const MAX_CLICK_AMOUNT = 3500;

export const HomeScreen = () => {
 //total amount of coins
 const [amount, setAmount] = useState(0);
 //amount of clicks
 const [clickedAmount, setClickedAmount] = useState(0);

 const insets = useSafeAreaInsets();
 const paddingBottom = Math.max(20, insets.bottom);

 //what happened when we press coin
 const handleClick = () => {
   setAmount(prev => prev + 1);
   setClickedAmount(prev => prev + 1);
 };

 return (
   <>
     <StatusBar backgroundColor={config().themeParams.header_bg_color} />
     <Screen style={{ paddingBottom }}>
       <Header amount={amount} />
       <View style={styles.screen}>
         <View style={styles.coin}>
           <Coin
             disabled={clickedAmount >= MAX_CLICK_AMOUNT}
             onClick={handleClick}></Coin>
         </View>
         <View style={styles.footer}>
           <Progress amount={clickedAmount} />
         </View>
       </View>
     </Screen>
   <>
 );
};

const styles = StyleSheet.create({
 screen: {
   flex: 1,
   gap: 20,
 },
 coin: {
   flex: 1,
   backgroundColor: config().themeParams.bg_color,
   alignItems: "center",
   justifyContent: "center",
 },
 footer: {
   padding: 20,
 },
});
Again, everything is pretty straightforward. The only thing that might raise some questions is clickedAmount & amount. These are essentially two identical values, so why do we need them? Here is the answer:
  • amount – the number of all user moments
  • clickedAmount – the number of times the user clicked the button.
Amount needs to be stored somewhere. And clickedAmount should be reset over time as we give the user more clicks. We haven't prescribed this functionality, so you can experiment with it if you like.
Then let's put all this into RootNavigator, and the navigator itself into App.tsx.
src/RootNavigator.tsx

import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { HomeScreen } from "./screens/HomeScreen";

const RootStack = createNativeStackNavigator();

export const RootNavigator = () => {
 return (
 <RootStack.Navigator screenOptions={{ headerShown: false }}>
 <RootStack.Screen name="Home" component={HomeScreen}></RootStack.Screen>
 </RootStack.Navigator>
 );
};
src/tApp.tsx

import React, { useEffect } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { NavigationContainer } from "@react-navigation/native";
import { RootNavigator } from "./RootNavigator";
import { config } from "../config";

export default function App() {
 useEffect(() => {
   config().expand();
 }, []);
 return (
   <SafeAreaProvider>
     <NavigationContainer>
       <RootNavigator />
     </NavigationContainer>
   </SafeAreaProvider>
 );
}
In App.tsx we call the expand method in useEffect. This method is needed to expand the application to full screen when running in Telegram.
See what the final code looks like in the link to the repository.
So we have a regular clicker with basic functionality.
I'd like to point out that in this example we get the user data from initDataUnsafe. And this is not the best solution, because according to the documentation it is better to fail our initData and use ApiKey from Telegram-bot. But our example is just a demonstration, so this option is enough.
It's also clear that using a user moc in a mobile application is not a good idea. It makes sense to handle and display authentication separately, or to make login from a guest account. In general, you can argue about this for a long time. But we leave it to your imagination – just clone the repository and play with it as you like. And we will move on.
Now let's look at what the Telegram client has to offer in terms of additional functionality. To do this, we will use Haptic Feedback from the WebApp library. It won't work for a mobile application, so we'll do the following.
Let's start with the Haptic library. We used expo-haptics because it has roughly similar arguments to Telegram's HapticFeedback. Since our project is written in pure React Native, we'll put expo first and then expo-haptics:
  • pnpx install-expo-modules@latest
  • pnpx expo install expo-haptics
  • cd ios && pod install
Next, let's write a hook to act as our wrapper:
src/useHaptics.ts

import { useEffect, useState } from "react";
import { Platform } from "react-native";
import {
 impactAsync,
 notificationAsync,
 NotificationFeedbackType,
 ImpactFeedbackStyle,
} from "expo-haptics";

type Haptics = {
 impactOccurred: (style: ImpactFeedbackStyle) => Promise<void>;
 notificationOccurred: (type: NotificationFeedbackType) => Promise<void>;
};

export const useHaptics = () => {
 const [haptics, setHaptics] = useState<Haptics>({
   impactOccurred: async _ => {},
   notificationOccurred: async _ => {},
 });

 useEffect(() => {
   if (Platform.OS == "web") {
     if (window.Telegram?.WebApp.HapticFeedback) {
       setHaptics(window.Telegram.WebApp.HapticFeedback);
       return;
     }
   }

   const impact = async (style: ImpactFeedbackStyle) =>
     await impactAsync(style);
   const notification = async (type: NotificationFeedbackType) =>
     await notificationAsync(type);
   setHaptics({ impactOccurred: impact, notificationOccurred: notification });
 }, []);

 return haptics;
};
This way, we can use HapticFeedback in both the Telegram mini-app and our regular app. The only thing left to do is to add haptic when you click on a coin, and that's it.
As with haptics, you can also try to create a repository to store the result. But this part is already on your side πŸ˜‰
The only thing left to do is to deploy the app on Telegram. But we'll talk about that in the next article.
How to build a Web App. Part II: Deploying Telegram mini-app
We're continuing our experiment with building an app in React Native that functions as a Telegram Web App
Dev.family will see you soon! πŸ’œ
Are you planning to integrate your app with a messenger? I will help you understand the key steps and give you recommendations for implementation.
George A.
Business Manager