React Native App Targeting Mobile, Web & Desktop With Expo & Tauri
Nowadays, developers can target web, mobile, and even desktop devices with the same JS code. In this article, we will check how to combine React Native apps targeting mobile and web platforms with a new desktop framework called Tauri.
Hey! But what actually is Tauri?
Tauri is a cross-platform solution for producing secure and performant desktop apps. It's an alternative to the mature and popular JS framework – Electron. However, these two have some architectural differences.
Electron ships with embedded Chromium and Node.js runtime that lets developers use all possible browser and node APIs, which opens the possibility to develop rich feature apps rapidly and share that business logic with web apps. However, for simple and smaller products, it may be a little overkill to bundle all possible functionalities offered by Chromium when not needed.
Tauri, on the other hand, embeds platform webviews (WebKit for macOS, Webview2 for Windows, and webkit2gtk for Linux) inside a native desktop window written and managed with Rust. This allows for smaller app binary sizes and the same flexibility in developing the UI side – or managing things like file management, native dialogs, and app updates offered by Electron. In the upcoming future, Tauri plans to expand also to mobile apps, which makes it quite an interesting alternative in the cross-platform world.
How can you make your React Native code run on Tauri?
Remember that we can use any JS framework for our frontend part in Tauri? That means we can use React Native Web combined with Webpack bundler and have our JS code shared between mobile apps, web apps, and apps targeting desktop devices. In this case, we will use yarn workspaces to create a monorepo structure in order to separate our apps, shared business logic, and shared UI. For mobile and web development, we will leverage Expo to have Webpack setup already configured (although it's not required, you can do it with bare React Native and React Native Web as well). For desktop development with Tauri, we will use the same Expo setup enhanced with Webpack which can resolve .tauri.[ext] files. Thanks to that, we will be able to write some Tauri-specific code.
The whole code can be found under this repository.
What does it look like?
The showcase repository is divided into four packages:
my-expo-app
- contains a mobile and web version of our React Native app; it consumes shared business logic and UImy-tauri-app
-contains a desktop Tauri app; it consumes shared business logic and UImy-shared-bl
- is our shared business logic modulemy-shared-ui
-is our shared UI module; it renders the same UI based on functionalities exposed from shared business logic
To make it possible for the app's packages to consume shared modules, we need to customize Metro and Webpack configs. If you want to make Webpack config customizable in create-expo-app
bootstrapped project, check Expo docs section. When we have our metro.config.js
and webpack.config.js
available, we need to apply small changes to ensure that our shared modules will be transpiled and resolved properly.
Metro & Webpack customization
metro.config.js
in my-expo-app
const path = require('path');
const { getDefaultConfig } = require('@expo/metro-config');
const escape = require('escape-string-regexp');
const exclusionList = require('metro-config/src/defaults/exclusionList');
const defaultConfig = getDefaultConfig(__dirname);
const sharedBLPak = require('../my-shared-bl/package.json');
const sharedUIPak = require('../my-shared-ui/package.json');
const sharedBLPath = path.resolve(__dirname, '..', 'my-shared-bl');
const sharedUIPath = path.resolve(__dirname, '..', 'my-shared-ui');
const modules = Object.keys({
...sharedBLPak.peerDependencies,
...sharedUIPak.peerDependencies,
});
const blockList = exclusionList(
modules.map(
(m) =>
new RegExp(`^(${escape(path.join(sharedBLPath, 'node_modules', m))})|(${escape(path.join(sharedUIPath, 'node_modules', m))})\\/.*$`)
)
);
const extraNodeModules = modules.reduce((acc, name) => {
acc[name] = path.join(__dirname, 'node_modules', name);
return acc;
}, {
'@tauri-and-expo/shared-bl': sharedBLPath,
'@tauri-and-expo/shared-ui': sharedUIPath,
});
module.exports = {
...defaultConfig,
projectRoot: __dirname,
watchFolders: [ sharedBLPath, sharedUIPath ],
// We need to make sure that only one version is loaded for peerDependencies
// So we block them at the shared-expo-bl & shared-ui and alias them to the versions in example's node_modules
resolver: {
...defaultConfig.resolver,
blockList,
extraNodeModules,
},
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
};
webpack.config.js
in my-expo-app
const path = require('path');
const createExpoWebpackConfigAsync = require('@expo/webpack-config');
const node_modules = path.join(__dirname, 'node_modules');
const sharedBLPak = require('../my-shared-bl/package.json');
const sharedUIPak = require('../my-shared-ui/package.json');
const sharedBLPath = path.resolve(__dirname, '..', 'my-shared-bl');
const sharedUIPath = path.resolve(__dirname, '..', 'my-shared-ui');
const modules = Object.keys({
...sharedBLPak.peerDependencies,
...sharedUIPak.peerDependencies,
});
module.exports = async function (env, argv) {
const config = await createExpoWebpackConfigAsync(env, argv);
// Handle shared-bl and shared-ui babel transpilation
config.module.rules.push({
test: /\.(js|jsx|ts|tsx)$/,
include: [ sharedBLPath, sharedUIPath ],
use: {
loader: 'babel-loader',
options: {
presets: [
'babel-preset-expo',
],
},
},
});
// We need to make sure that only one version is loaded for peerDependencies
// So we alias them to the versions in example's node_modules
Object.assign(config.resolve.alias, {
...modules.reduce((acc, name) => {
acc[name] = path.join(__dirname, 'node_modules', name);
return acc;
}, {}),
'@tauri-and-expo/shared-bl': sharedBLPath,
'@tauri-and-expo/shared-ui': sharedUIPath,
'react': path.resolve(node_modules, 'react'),
'react-native': path.resolve(node_modules, 'react-native-web'),
'react-native-web': path.resolve(node_modules, 'react-native-web'),
});
return config;
};
webpack.config.js
in my-tauri-app
const path = require('path');
const createExpoWebpackConfigAsync = require('@expo/webpack-config');
const node_modules = path.join(__dirname, 'node_modules');
const sharedBLPak = require('../my-shared-bl/package.json');
const sharedUIPak = require('../my-shared-ui/package.json');
const sharedBLPath = path.resolve(__dirname, '..', 'my-shared-bl');
const sharedUIPath = path.resolve(__dirname, '..', 'my-shared-ui');
const modules = Object.keys({
...sharedBLPak.peerDependencies,
...sharedUIPak.peerDependencies,
});
module.exports = async function (env, argv) {
const config = await createExpoWebpackConfigAsync(env, argv);
// handle webpack resolver with Tauri specific files
config.resolve.extensions = [ '.tauri.tsx', '.web.tsx', '.tsx', '.tauri.ts', '.web.ts', '.ts', 'tauri.js', '.web.js', '.js', '.json', '...' ];
// Handle shared-bl and shared-ui babel transpilation
config.module.rules.push({
test: /\.(js|jsx|ts|tsx)$/,
include: [ sharedBLPath, sharedUIPath ],
use: {
loader: 'babel-loader',
options: {
presets: [
'babel-preset-expo',
],
},
},
});
// We need to make sure that only one version is loaded for peerDependencies
// So we alias them to the versions in example's node_modules
Object.assign(config.resolve.alias, {
...modules.reduce((acc, name) => {
acc[name] = path.join(__dirname, 'node_modules', name);
return acc;
}, {}),
'@tauri-and-expo/shared-bl': sharedBLPath,
'@tauri-and-expo/shared-ui': sharedUIPath,
'react': path.resolve(node_modules, 'react'),
'react-native': path.resolve(node_modules, 'react-native-web'),
'react-native-web': path.resolve(node_modules, 'react-native-web'),
});
return config;
};
Here, we are aliasing our shared modules so that app modules can reference them correctly within monorepo. Additionally, we are handling Tauri-specific files so that the Tauri app can use them instead of mobile/web files.
Platform-specific and shared code
Our shared module showcase how to share our JS UI and business logic, as well as how to write different implementations for mobile and web app vs desktop app. UI shared module has a single <App />
component which renders two labels, two buttons, and a list populated with API data.
export const App: React.FC = () => {
const platformTuple = useAtomValue(atom(async () => PlatformModule.getPlatform()));
const [ photos, setPhotos ] = useAtom(atom<PhotoObject[]>([]));
const sendNotification = React.useCallback(() => {
NotificationModule.sendNotification('Notification from shared UI', 'How cool is that?');
}, []);
const fetchData = React.useCallback(async () => {
const response = await HttpClientModule.get<PhotoObject[]>('https://jsonplaceholder.typicode.com/photos?albumId=1');
setPhotos(response.data ?? []);
}, [ setPhotos ]);
return (
<SafeAreaView style={styles.container}>
<Text>Open up App.js to start working on your app!</Text>
<Text>Hello from {platformTuple.platform} {platformTuple.os}</Text>
<Button onPress={sendNotification} title="Send notification" />
<Button onPress={fetchData} title="Fetch photos" />
<View style={styles.list}>
<FlashList
data={photos}
renderItem={({ item, index }) => {
return (
<View style={styles.item}>
<View style={styles.itemElement}>
<Image source={ { uri: item.url } } style={styles.itemImage} />
</View>
<View style={styles.itemElement}>
<Text style={styles.itemText}>{item.title} {index}</Text>
</View>
</View>
);
}}
estimatedItemSize={200}
/>
</View>
<StatusBar />
</SafeAreaView>
);
};
This component uses three shared modules: HttpClientModule
, NotificationModule
& PlatformModule
.These are abstractions over APIs that are available in React Native and Tauri. Let's take a look at one of them:
notification.tsx
import * as Notifications from 'expo-notifications';
import type { NotificationModuleInterface, StaticImplements } from './types';
Notifications.setNotificationHandler({
handleNotification: async () => ({
priority: Notifications.AndroidNotificationPriority.HIGH,
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
export class NotificationModule implements StaticImplements<NotificationModuleInterface, typeof NotificationModule> {
static async sendNotification(title: string, body?: string) {
const permissionsStatus = await Notifications.getPermissionsAsync();
if (
!permissionsStatus.granted &&
permissionsStatus.ios?.status !== Notifications.IosAuthorizationStatus.PROVISIONAL &&
permissionsStatus.ios?.status !== Notifications.IosAuthorizationStatus.AUTHORIZED
) {
const permissionResult = await Notifications.requestPermissionsAsync();
if (
!permissionResult.granted &&
permissionResult.ios?.status !== Notifications.IosAuthorizationStatus.PROVISIONAL &&
permissionResult.ios?.status !== Notifications.IosAuthorizationStatus.AUTHORIZED
) {
return;
}
}
Notifications.scheduleNotificationAsync({
content: { body, title },
trigger: {
seconds: 5,
},
});
}
}
notification.tauri.tsx
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/api/notification';
import type { NotificationModuleInterface, StaticImplements } from './types';
export class NotificationModule implements StaticImplements<NotificationModuleInterface, typeof NotificationModule> {
static async sendNotification(title: string, body?: string) {
if (!await isPermissionGranted()) {
const permissionResult = await requestPermission();
if (permissionResult !== 'granted') {
return;
}
}
sendNotification({ body, title });
}
}
Here, thanks to the common TypeScript interface, we can declare the same class for React Native and Tauri environments with different implementations for each of them. React Native class leverages expo-notifications
package to display in-app notifications, whereas the Tauri class uses a built-in notification API. This expands flexibility known from React Native in which we can write platform-specific code to the Tauri world. Accordingly, our UI will successfully use the same classes and functions without knowing implementation details.
Run your React Native code on all devices
Mobile development and web development are getting increasingly popular, with many companies putting their bets on cross-platform technologies like React Native to have a greater development pace on all platforms without involving many developers. With just a few customizations, it may be possible to make React Native code run on all desktop devices, including Linux distros.