Sharing Code with a Web App

A monorepo that shares types and logic between a React Native app and a React web app, with platform code kept at the edges.

Syntaximport { cartReducer, cartTotalPence, type CartLine } from "core";

Most of a product is not platform code. Types, validation, API calls, pricing rules, reducers: a React web app and a React Native app can run the same copy. A monorepo is how that happens. One repository holds apps/mobile and apps/web beside packages/core, and the package manager's workspaces feature links them so import { cartTotalPence } from "core" resolves to local source rather than a published package.

What shares and what does not

Shareable: plain TypeScript, and React itself - hooks, context, reducers. Not shareable: anything that draws. View and Text are not div and span, StyleSheet is not CSS, and there is no cascade for a shared stylesheet to rely on. Navigation differs. Storage differs, since the phone has AsyncStorage and the browser has localStorage. Native modules have no browser equivalent at all. Declare those as a small interface in the shared package and let each app supply its own implementation: the port stays in packages/core, the adapters live in the apps.

Setup, and the versions that matter

Since SDK 52, expo/metro-config detects a monorepo, so the watchFolders and resolver.nodeModulesPaths patches that older guides show are no longer needed. From SDK 55, autolinking of native modules is enabled for monorepo apps automatically. Type-only imports keep types flowing without code: import { type Cart } from "core" and import type { Cart } from "core" are the same thing, and both vanish at build time.

What trips people

One react version across the whole workspace. Two copies produce hook errors that look like your own bug.

Example

Example · tsx
// packages/core/src/cart.ts
// Plain TypeScript: no react-native import, so the web app can use it too
export type CartLine = { sku: string; unitPence: number; quantity: number };

export type CartAction =
  | { type: "add"; line: CartLine }
  | { type: "remove"; sku: string };

export function cartReducer(lines: CartLine[], action: CartAction): CartLine[] {
  switch (action.type) {
    case "add": {
      const existing = lines.find((line) => line.sku === action.line.sku);
      if (!existing) return [...lines, action.line];
      return lines.map((line) =>
        line.sku === action.line.sku
          ? { ...line, quantity: line.quantity + action.line.quantity }
          : line
      );
    }
    case "remove":
      return lines.filter((line) => line.sku !== action.sku);
  }
}

export function cartTotalPence(lines: CartLine[]): number {
  return lines.reduce((total, line) => total + line.unitPence * line.quantity, 0);
}

const lines = cartReducer([], {
  type: "add",
  line: { sku: "mug", unitPence: 950, quantity: 2 },
});
console.log(cartTotalPence(lines));
// Output: 1900

When to use it

  • Keeping one definition of an API response so a backend change breaks both clients at compile time.
  • Reusing pricing, tax or validation rules between a web checkout and the mobile app.
  • Letting a web team and a mobile team ship from one repository without copying files.
  • Testing business logic once, in Node, instead of once per platform.

More examples

Workspace layout

Both apps list core as a dependency and the workspace links it to the folder on disk, so editing shared code updates both apps with no publish step. Create the mobile app in place with a command such as npx create-expo-app@latest apps/mobile; SDK 52 and later need no Metro changes to find packages/core.

Example · text
package.json            "workspaces": ["apps/*", "packages/*"]
apps/
  mobile/               the Expo app - app.json, metro.config.js, src/app
    package.json        depends on "core": "*"
  web/                  the React web app
    package.json        depends on "core": "*"
packages/
  core/
    src/cart.ts         pricing rules, reducers
    src/api.ts          fetch calls and response types
    src/storage.ts      an interface, not an implementation
    package.json        name: "core", main: "src/index.ts"

A port in the package, adapters in the apps

Shared code takes a Storage and never learns which one it got, so the one piece that genuinely differs stays three lines long per platform. This is the pattern for every platform boundary: storage, analytics, file access, biometrics.

Example · tsx
// packages/core/src/storage.ts - the port: shared and platform-free
export type Storage = {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
};

// apps/mobile/src/lib/storage.ts - the native adapter
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { Storage } from "core";

export const storage: Storage = {
  get: (key) => AsyncStorage.getItem(key),
  set: (key, value) => AsyncStorage.setItem(key, value),
};

// apps/web/src/lib/storage.ts - the browser adapter
import type { Storage } from "core";

export const storage: Storage = {
  get: async (key) => window.localStorage.getItem(key),
  set: async (key, value) => window.localStorage.setItem(key, value),
};

The mobile app consuming shared logic

On screen: a total above a blue button, and each tap raises the total by £9.50. Only the JSX and the styles are mobile-specific - the arithmetic and the action shapes are the shared package. Note the inline type import, which is the same as a separate import type line.

Example · tsx
// apps/mobile/src/app/cart.tsx
import { useReducer } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { cartReducer, cartTotalPence, type CartLine } from "core";

const mug: CartLine = { sku: "mug", unitPence: 950, quantity: 1 };

export default function CartScreen() {
  // useReducer takes the shared reducer unchanged; the web app calls the same one
  const [lines, dispatch] = useReducer(cartReducer, [] as CartLine[]);
  return (
    <View style={styles.screen}>
      <Text style={styles.total}>
        {`Total £${(cartTotalPence(lines) / 100).toFixed(2)}`}
      </Text>
      <Pressable style={styles.button} onPress={() => dispatch({ type: "add", line: mug })}>
        <Text style={styles.buttonLabel}>Add mug</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 24, gap: 16 },
  total: { fontSize: 24, fontWeight: "600" },
  button: { padding: 14, borderRadius: 10, backgroundColor: "#1d4ed8" },
  buttonLabel: { color: "#ffffff", textAlign: "center" },
});

Discussion

  • Be the first to comment on this lesson.