Back to Blog

React Native and React Web: Sharing Code Without Sharing Pain

Monorepo strategies, shared business logic, platform-specific UI layers, and where the abstraction breaks down — a battle-tested architecture guide.


The Promise and the Reality

The pitch is compelling: "It's all React! Share components between your web app and mobile apps. Write once, run everywhere."

The reality is more nuanced. Some code shares beautifully. Some code shouldn't be shared at all. And the difference isn't always obvious until you're deep into a project wondering why everything feels harder than it should.

THE PROMISE
────────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────┐
│                     Shared React Code                            │
│                            │                                     │
│              ┌─────────────┴─────────────┐                      │
│              ▼                           ▼                       │
│         React Web                   React Native                 │
│                                                                  │
│         "Write once, run everywhere"                             │
└─────────────────────────────────────────────────────────────────┘

THE REALITY
────────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────┐
│                                                                  │
│   Shared (works great):           Platform-specific (don't try):│
│   ├── Business logic              ├── Navigation                │
│   ├── API clients                 ├── UI components             │
│   ├── State management            ├── Gestures/animations       │
│   ├── Validation                  ├── Native modules            │
│   ├── Types/interfaces            ├── File system access        │
│   └── Utilities                   └── Push notifications        │
│                                                                  │
│   Partially shared (careful):                                    │
│   ├── Hooks (some)                                              │
│   ├── Design tokens                                             │
│   └── Data transformations                                      │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

This post is about drawing those lines correctly from the start.


The Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                         MONOREPO                                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  apps/                                                          │
│  ├── web/              Next.js application                      │
│  ├── mobile/           React Native (Expo) application          │
│  └── admin/            Another web app (optional)               │
│                                                                  │
│  packages/                                                       │
│  ├── core/             Business logic, API, state (NO UI)       │
│  ├── ui-web/           Web-specific components                  │
│  ├── ui-native/        Native-specific components               │
│  ├── ui-shared/        Truly cross-platform UI (rare)           │
│  ├── config/           Shared ESLint, TypeScript configs        │
│  └── types/            Shared TypeScript types                  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

The key insight: share by capability, not by platform. Business logic doesn't know about platforms. UI inherently does.


Monorepo Setup

Turborepo Configuration

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "test": {
      "dependsOn": ["^build"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    }
  }
}

Package Structure

// package.json (root)
{
  "name": "acme-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "test": "turbo test",
    "typecheck": "turbo typecheck"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.0.0"
  }
}
// packages/core/package.json
{
  "name": "@acme/core",
  "version": "0.0.0",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@tanstack/react-query": "^5.0.0",
    "zod": "^3.22.0",
    "zustand": "^4.4.0"
  },
  "peerDependencies": {
    "react": "^18.0.0"
  }
}

TypeScript Configuration

// packages/config/tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}

// packages/core/tsconfig.json
{
  "extends": "@acme/config/tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "jsx": "react-jsx"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

// apps/web/tsconfig.json
{
  "extends": "@acme/config/tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "plugins": [{ "name": "next" }],
    "paths": {
      "@/*": ["./src/*"],
      "@acme/core": ["../../packages/core/src"],
      "@acme/types": ["../../packages/types/src"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

The Core Package: What to Share

The @acme/core package contains everything that has no platform dependencies.

API Client

// packages/core/src/api/client.ts

import { z } from 'zod';

export interface ApiConfig {
  baseUrl: string;
  getAccessToken: () => Promise<string | null>;
  onUnauthorized?: () => void;
}

let config: ApiConfig | null = null;

export function initializeApi(cfg: ApiConfig) {
  config = cfg;
}

class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public code?: string
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

async function fetchWithAuth<T>(
  endpoint: string,
  options: RequestInit = {},
  schema?: z.ZodSchema<T>
): Promise<T> {
  if (!config) {
    throw new Error('API not initialized. Call initializeApi first.');
  }

  const token = await config.getAccessToken();

  const response = await fetch(`${config.baseUrl}${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
      ...options.headers,
    },
  });

  if (response.status === 401) {
    config.onUnauthorized?.();
    throw new ApiError('Unauthorized', 401);
  }

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new ApiError(error.message || 'Request failed', response.status);
  }

  const data = await response.json();

  if (schema) {
    return schema.parse(data);
  }

  return data as T;
}

export const api = {
  get: <T>(endpoint: string, schema?: z.ZodSchema<T>) =>
    fetchWithAuth<T>(endpoint, { method: 'GET' }, schema),

  post: <T>(endpoint: string, body: unknown, schema?: z.ZodSchema<T>) =>
    fetchWithAuth<T>(
      endpoint,
      { method: 'POST', body: JSON.stringify(body) },
      schema
    ),

  put: <T>(endpoint: string, body: unknown, schema?: z.ZodSchema<T>) =>
    fetchWithAuth<T>(
      endpoint,
      { method: 'PUT', body: JSON.stringify(body) },
      schema
    ),

  delete: <T>(endpoint: string, schema?: z.ZodSchema<T>) =>
    fetchWithAuth<T>(endpoint, { method: 'DELETE' }, schema),
};

Schemas and Types

// packages/core/src/schemas/user.ts

import { z } from 'zod';

export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
  avatarUrl: z.string().url().nullable(),
  role: z.enum(['user', 'admin', 'moderator']),
  createdAt: z.string().datetime(),
});

export const CreateUserSchema = z.object({
  email: z.string().email('Invalid email'),
  name: z.string().min(1, 'Name is required'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

export const LoginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(1, 'Password is required'),
});

export type User = z.infer<typeof UserSchema>;
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type LoginInput = z.infer<typeof LoginSchema>;

State Management

// packages/core/src/stores/auth.ts

import { create } from 'zustand';
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
import { User } from '../schemas/user';

interface AuthState {
  user: User | null;
  accessToken: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;

  // Actions
  setAuth: (user: User, accessToken: string) => void;
  clearAuth: () => void;
  setLoading: (loading: boolean) => void;
}

// Storage adapter will be injected by platform
let storage: StateStorage | null = null;

export function initializeAuthStorage(storageAdapter: StateStorage) {
  storage = storageAdapter;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      accessToken: null,
      isAuthenticated: false,
      isLoading: true,

      setAuth: (user, accessToken) =>
        set({
          user,
          accessToken,
          isAuthenticated: true,
          isLoading: false,
        }),

      clearAuth: () =>
        set({
          user: null,
          accessToken: null,
          isAuthenticated: false,
          isLoading: false,
        }),

      setLoading: (isLoading) => set({ isLoading }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => {
        if (!storage) {
          // Fallback to memory storage if not initialized
          const memoryStorage: Record<string, string> = {};
          return {
            getItem: (name) => memoryStorage[name] ?? null,
            setItem: (name, value) => { memoryStorage[name] = value; },
            removeItem: (name) => { delete memoryStorage[name]; },
          };
        }
        return storage;
      }),
      partialize: (state) => ({
        user: state.user,
        accessToken: state.accessToken,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
);

React Query Hooks

// packages/core/src/hooks/useUser.ts

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { UserSchema, User, CreateUserInput } from '../schemas/user';
import { z } from 'zod';

const UsersResponseSchema = z.object({
  users: z.array(UserSchema),
  total: z.number(),
});

export function useUsers(params?: { page?: number; limit?: number }) {
  const { page = 1, limit = 20 } = params ?? {};

  return useQuery({
    queryKey: ['users', { page, limit }],
    queryFn: () =>
      api.get(`/users?page=${page}&limit=${limit}`, UsersResponseSchema),
  });
}

export function useUser(id: string) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => api.get(`/users/${id}`, UserSchema),
    enabled: !!id,
  });
}

export function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input: CreateUserInput) =>
      api.post('/users', input, UserSchema),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

export function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>
      api.put(`/users/${id}`, data, UserSchema),
    onSuccess: (data) => {
      queryClient.setQueryData(['user', data.id], data);
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Business Logic

// packages/core/src/services/auth.ts

import { api } from '../api/client';
import { useAuthStore } from '../stores/auth';
import { LoginInput, UserSchema } from '../schemas/user';
import { z } from 'zod';

const AuthResponseSchema = z.object({
  user: UserSchema,
  accessToken: z.string(),
  refreshToken: z.string(),
});

export const authService = {
  async login(input: LoginInput) {
    const response = await api.post('/auth/login', input, AuthResponseSchema);

    useAuthStore.getState().setAuth(response.user, response.accessToken);

    return response;
  },

  async logout() {
    try {
      await api.post('/auth/logout', {});
    } finally {
      useAuthStore.getState().clearAuth();
    }
  },

  async refreshToken(refreshToken: string) {
    const response = await api.post(
      '/auth/refresh',
      { refreshToken },
      AuthResponseSchema
    );

    useAuthStore.getState().setAuth(response.user, response.accessToken);

    return response;
  },

  async getCurrentUser() {
    const user = await api.get('/auth/me', UserSchema);
    const token = useAuthStore.getState().accessToken;

    if (token) {
      useAuthStore.getState().setAuth(user, token);
    }

    return user;
  },
};

Utility Functions

// packages/core/src/utils/format.ts

export function formatCurrency(
  amount: number,
  currency = 'USD',
  locale = 'en-US'
): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount);
}

export function formatDate(
  date: string | Date,
  options?: Intl.DateTimeFormatOptions,
  locale = 'en-US'
): string {
  const d = typeof date === 'string' ? new Date(date) : date;
  return new Intl.DateTimeFormat(locale, {
    dateStyle: 'medium',
    ...options,
  }).format(d);
}

export function formatRelativeTime(date: string | Date): string {
  const d = typeof date === 'string' ? new Date(date) : date;
  const now = new Date();
  const diffMs = now.getTime() - d.getTime();
  const diffMins = Math.floor(diffMs / 60000);
  const diffHours = Math.floor(diffMs / 3600000);
  const diffDays = Math.floor(diffMs / 86400000);

  if (diffMins < 1) return 'Just now';
  if (diffMins < 60) return `${diffMins}m ago`;
  if (diffHours < 24) return `${diffHours}h ago`;
  if (diffDays < 7) return `${diffDays}d ago`;

  return formatDate(d);
}

// packages/core/src/utils/validation.ts

export function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export function isStrongPassword(password: string): {
  valid: boolean;
  errors: string[];
} {
  const errors: string[] = [];

  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('Password must contain an uppercase letter');
  }
  if (!/[a-z]/.test(password)) {
    errors.push('Password must contain a lowercase letter');
  }
  if (!/[0-9]/.test(password)) {
    errors.push('Password must contain a number');
  }

  return { valid: errors.length === 0, errors };
}

Core Package Export

// packages/core/src/index.ts

// API
export { api, initializeApi, type ApiConfig } from './api/client';

// Schemas & Types
export * from './schemas/user';
export * from './schemas/product';

// Stores
export { useAuthStore, initializeAuthStorage } from './stores/auth';
export { useCartStore } from './stores/cart';

// Hooks
export * from './hooks/useUser';
export * from './hooks/useProducts';

// Services
export { authService } from './services/auth';

// Utils
export * from './utils/format';
export * from './utils/validation';

Platform-Specific Initialization

Web (Next.js)

// apps/web/src/lib/platform.ts

import { initializeApi, initializeAuthStorage, useAuthStore } from '@acme/core';

// Web-specific storage adapter
const webStorage = {
  getItem: (name: string) => {
    if (typeof window === 'undefined') return null;
    return localStorage.getItem(name);
  },
  setItem: (name: string, value: string) => {
    if (typeof window !== 'undefined') {
      localStorage.setItem(name, value);
    }
  },
  removeItem: (name: string) => {
    if (typeof window !== 'undefined') {
      localStorage.removeItem(name);
    }
  },
};

export function initializePlatform() {
  // Initialize storage
  initializeAuthStorage(webStorage);

  // Initialize API
  initializeApi({
    baseUrl: process.env.NEXT_PUBLIC_API_URL || '/api',
    getAccessToken: async () => {
      return useAuthStore.getState().accessToken;
    },
    onUnauthorized: () => {
      useAuthStore.getState().clearAuth();
      window.location.href = '/login';
    },
  });
}
// apps/web/src/app/providers.tsx

'use client';

import { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initializePlatform } from '@/lib/platform';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [isInitialized, setIsInitialized] = useState(false);

  useEffect(() => {
    initializePlatform();
    setIsInitialized(true);
  }, []);

  if (!isInitialized) {
    return null; // Or loading spinner
  }

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

React Native (Expo)

// apps/mobile/src/lib/platform.ts

import * as SecureStore from 'expo-secure-store';
import { initializeApi, initializeAuthStorage, useAuthStore } from '@acme/core';
import { router } from 'expo-router';

// React Native storage adapter using SecureStore
const nativeStorage = {
  getItem: async (name: string) => {
    try {
      return await SecureStore.getItemAsync(name);
    } catch {
      return null;
    }
  },
  setItem: async (name: string, value: string) => {
    try {
      await SecureStore.setItemAsync(name, value);
    } catch (error) {
      console.error('SecureStore setItem error:', error);
    }
  },
  removeItem: async (name: string) => {
    try {
      await SecureStore.deleteItemAsync(name);
    } catch (error) {
      console.error('SecureStore removeItem error:', error);
    }
  },
};

export function initializePlatform() {
  // Initialize storage
  initializeAuthStorage(nativeStorage);

  // Initialize API
  initializeApi({
    baseUrl: process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com',
    getAccessToken: async () => {
      return useAuthStore.getState().accessToken;
    },
    onUnauthorized: () => {
      useAuthStore.getState().clearAuth();
      router.replace('/login');
    },
  });
}
// apps/mobile/src/app/_layout.tsx

import { useEffect, useState } from 'react';
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initializePlatform } from '@/lib/platform';

export default function RootLayout() {
  const [queryClient] = useState(() => new QueryClient());
  const [isInitialized, setIsInitialized] = useState(false);

  useEffect(() => {
    initializePlatform();
    setIsInitialized(true);
  }, []);

  if (!isInitialized) {
    return null; // Or splash screen
  }

  return (
    <QueryClientProvider client={queryClient}>
      <Stack />
    </QueryClientProvider>
  );
}

The UI Layer: What NOT to Share

UI components should generally be platform-specific. Here's why:

WEB:                                NATIVE:
────────────────────────────        ────────────────────────────
<button                             <Pressable
  className="px-4 py-2              style={styles.button}
    bg-blue-500 text-white          onPress={handlePress}
    rounded hover:bg-blue-600"    >
  onClick={handleClick}               <Text style={styles.text}>
>                                       Click me
  Click me                            </Text>
</button>                           </Pressable>

- CSS classes                       - StyleSheet objects
- DOM events (onClick)              - Native events (onPress)
- Hover states                      - No hover (touch only)
- Cursor changes                    - Haptic feedback
- :focus-visible                    - Different focus handling

Shared Design Tokens (What DOES Share)

// packages/ui-shared/src/tokens.ts

// These are platform-agnostic values

export const colors = {
  primary: {
    50: '#eff6ff',
    100: '#dbeafe',
    200: '#bfdbfe',
    300: '#93c5fd',
    400: '#60a5fa',
    500: '#3b82f6',
    600: '#2563eb',
    700: '#1d4ed8',
    800: '#1e40af',
    900: '#1e3a8a',
  },
  gray: {
    50: '#f9fafb',
    100: '#f3f4f6',
    200: '#e5e7eb',
    300: '#d1d5db',
    400: '#9ca3af',
    500: '#6b7280',
    600: '#4b5563',
    700: '#374151',
    800: '#1f2937',
    900: '#111827',
  },
  success: '#10b981',
  warning: '#f59e0b',
  error: '#ef4444',
} as const;

export const spacing = {
  0: 0,
  1: 4,
  2: 8,
  3: 12,
  4: 16,
  5: 20,
  6: 24,
  8: 32,
  10: 40,
  12: 48,
  16: 64,
} as const;

export const typography = {
  fontSizes: {
    xs: 12,
    sm: 14,
    base: 16,
    lg: 18,
    xl: 20,
    '2xl': 24,
    '3xl': 30,
    '4xl': 36,
  },
  fontWeights: {
    normal: '400',
    medium: '500',
    semibold: '600',
    bold: '700',
  },
  lineHeights: {
    tight: 1.25,
    normal: 1.5,
    relaxed: 1.75,
  },
} as const;

export const radii = {
  none: 0,
  sm: 4,
  md: 8,
  lg: 12,
  xl: 16,
  full: 9999,
} as const;

export const shadows = {
  sm: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.05,
    shadowRadius: 2,
    elevation: 1,
  },
  md: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.1,
    shadowRadius: 6,
    elevation: 3,
  },
  lg: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 10 },
    shadowOpacity: 0.1,
    shadowRadius: 15,
    elevation: 5,
  },
} as const;

Web UI Components

// packages/ui-web/src/Button.tsx

import { forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from './utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        primary: 'bg-primary-500 text-white hover:bg-primary-600',
        secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
        outline: 'border border-gray-300 bg-transparent hover:bg-gray-50',
        ghost: 'hover:bg-gray-100',
        destructive: 'bg-error text-white hover:bg-red-600',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
);

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, loading, children, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={cn(buttonVariants({ variant, size }), className)}
        disabled={disabled || loading}
        {...props}
      >
        {loading && (
          <svg
            className="mr-2 h-4 w-4 animate-spin"
            fill="none"
            viewBox="0 0 24 24"
          >
            <circle
              className="opacity-25"
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              strokeWidth="4"
            />
            <path
              className="opacity-75"
              fill="currentColor"
              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
            />
          </svg>
        )}
        {children}
      </button>
    );
  }
);

Native UI Components

// packages/ui-native/src/Button.tsx

import {
  Pressable,
  Text,
  StyleSheet,
  ActivityIndicator,
  type PressableProps,
  type StyleProp,
  type ViewStyle,
  type TextStyle,
} from 'react-native';
import { colors, spacing, typography, radii } from '@acme/ui-shared/tokens';

type Variant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
type Size = 'sm' | 'md' | 'lg';

interface ButtonProps extends Omit<PressableProps, 'style'> {
  variant?: Variant;
  size?: Size;
  loading?: boolean;
  children: string;
  style?: StyleProp<ViewStyle>;
}

export function Button({
  variant = 'primary',
  size = 'md',
  loading = false,
  disabled,
  children,
  style,
  ...props
}: ButtonProps) {
  const isDisabled = disabled || loading;

  return (
    <Pressable
      style={({ pressed }) => [
        styles.base,
        styles[`${variant}Container`],
        styles[`${size}Container`],
        pressed && styles[`${variant}Pressed`],
        isDisabled && styles.disabled,
        style,
      ]}
      disabled={isDisabled}
      {...props}
    >
      {loading && (
        <ActivityIndicator
          size="small"
          color={variant === 'primary' ? '#fff' : colors.primary[500]}
          style={styles.loader}
        />
      )}
      <Text
        style={[
          styles.text,
          styles[`${variant}Text`],
          styles[`${size}Text`],
        ]}
      >
        {children}
      </Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  base: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    borderRadius: radii.md,
  },

  // Variants
  primaryContainer: {
    backgroundColor: colors.primary[500],
  },
  primaryPressed: {
    backgroundColor: colors.primary[600],
  },
  primaryText: {
    color: '#fff',
  },

  secondaryContainer: {
    backgroundColor: colors.gray[100],
  },
  secondaryPressed: {
    backgroundColor: colors.gray[200],
  },
  secondaryText: {
    color: colors.gray[900],
  },

  outlineContainer: {
    backgroundColor: 'transparent',
    borderWidth: 1,
    borderColor: colors.gray[300],
  },
  outlinePressed: {
    backgroundColor: colors.gray[50],
  },
  outlineText: {
    color: colors.gray[900],
  },

  ghostContainer: {
    backgroundColor: 'transparent',
  },
  ghostPressed: {
    backgroundColor: colors.gray[100],
  },
  ghostText: {
    color: colors.gray[900],
  },

  destructiveContainer: {
    backgroundColor: colors.error,
  },
  destructivePressed: {
    backgroundColor: '#dc2626',
  },
  destructiveText: {
    color: '#fff',
  },

  // Sizes
  smContainer: {
    height: 32,
    paddingHorizontal: spacing[3],
  },
  smText: {
    fontSize: typography.fontSizes.sm,
  },

  mdContainer: {
    height: 40,
    paddingHorizontal: spacing[4],
  },
  mdText: {
    fontSize: typography.fontSizes.sm,
  },

  lgContainer: {
    height: 48,
    paddingHorizontal: spacing[6],
  },
  lgText: {
    fontSize: typography.fontSizes.base,
  },

  // States
  disabled: {
    opacity: 0.5,
  },

  // Common
  text: {
    fontWeight: typography.fontWeights.medium,
  },
  loader: {
    marginRight: spacing[2],
  },
});

Shared Hooks Pattern

Some hooks can be shared, but need platform-specific implementations injected.

// packages/core/src/hooks/useMediaQuery.ts

// This hook needs different implementations per platform

export interface MediaQueryMatcher {
  matches: (query: string) => boolean;
  subscribe: (query: string, callback: (matches: boolean) => void) => () => void;
}

let matcher: MediaQueryMatcher | null = null;

export function setMediaQueryMatcher(m: MediaQueryMatcher) {
  matcher = m;
}

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => matcher?.matches(query) ?? false);

  useEffect(() => {
    if (!matcher) return;

    setMatches(matcher.matches(query));
    return matcher.subscribe(query, setMatches);
  }, [query]);

  return matches;
}
// apps/web/src/lib/media-query.ts

import { setMediaQueryMatcher } from '@acme/core';

setMediaQueryMatcher({
  matches: (query) => window.matchMedia(query).matches,
  subscribe: (query, callback) => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => callback(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  },
});
// apps/mobile/src/lib/media-query.ts

import { Dimensions } from 'react-native';
import { setMediaQueryMatcher } from '@acme/core';

// Simplified - real implementation would parse query
setMediaQueryMatcher({
  matches: (query) => {
    const { width } = Dimensions.get('window');
    if (query.includes('min-width: 768px')) return width >= 768;
    if (query.includes('min-width: 1024px')) return width >= 1024;
    return false;
  },
  subscribe: (query, callback) => {
    const subscription = Dimensions.addEventListener('change', ({ window }) => {
      if (query.includes('min-width: 768px')) {
        callback(window.width >= 768);
      }
      // ... handle other queries
    });
    return () => subscription.remove();
  },
});

Where It Breaks Down

Navigation is fundamentally different:

WEB:                                NATIVE:
────────────────────────────        ────────────────────────────
- URL-based                         - Stack/Tab-based
- Browser history                   - Native navigation stack
- Link component                    - Different gesture handling
- Query parameters                  - Deep linking setup
- Static routes                     - Screen options

Don't try to share navigation logic. Use platform-specific routers:

  • Web: Next.js App Router
  • Native: Expo Router or React Navigation

Gestures and Animations

// DON'T try to share this

// Web version
const handleDrag = (e: React.DragEvent) => {
  // Web Drag API
};

// Native version (react-native-gesture-handler)
const panGesture = Gesture.Pan()
  .onUpdate((e) => {
    // Gesture handler
  })
  .onEnd((e) => {
    // Physics-based animations
  });

Forms

Forms can share validation logic but not input components:

// packages/core/src/hooks/useLoginForm.ts

// SHARED: Validation and state logic
import { useForm as useReactHookForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoginSchema, LoginInput } from '../schemas/user';
import { authService } from '../services/auth';

export function useLoginForm(options?: { onSuccess?: () => void }) {
  const form = useReactHookForm<LoginInput>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const onSubmit = async (data: LoginInput) => {
    try {
      await authService.login(data);
      options?.onSuccess?.();
    } catch (error) {
      form.setError('root', {
        message: error instanceof Error ? error.message : 'Login failed',
      });
    }
  };

  return {
    ...form,
    onSubmit: form.handleSubmit(onSubmit),
  };
}
// apps/web/src/components/LoginForm.tsx

// WEB: Platform-specific UI
import { useLoginForm } from '@acme/core';
import { Input, Button } from '@acme/ui-web';

export function LoginForm() {
  const { register, formState, onSubmit } = useLoginForm({
    onSuccess: () => router.push('/dashboard'),
  });

  return (
    <form onSubmit={onSubmit}>
      <Input
        {...register('email')}
        type="email"
        placeholder="Email"
        error={formState.errors.email?.message}
      />
      <Input
        {...register('password')}
        type="password"
        placeholder="Password"
        error={formState.errors.password?.message}
      />
      <Button type="submit" loading={formState.isSubmitting}>
        Sign In
      </Button>
    </form>
  );
}
// apps/mobile/src/components/LoginForm.tsx

// NATIVE: Platform-specific UI
import { Controller } from 'react-hook-form';
import { useLoginForm } from '@acme/core';
import { Input, Button } from '@acme/ui-native';

export function LoginForm() {
  const { control, formState, onSubmit } = useLoginForm({
    onSuccess: () => router.replace('/dashboard'),
  });

  return (
    <View>
      <Controller
        control={control}
        name="email"
        render={({ field }) => (
          <Input
            value={field.value}
            onChangeText={field.onChange}
            placeholder="Email"
            keyboardType="email-address"
            autoCapitalize="none"
            error={formState.errors.email?.message}
          />
        )}
      />
      <Controller
        control={control}
        name="password"
        render={({ field }) => (
          <Input
            value={field.value}
            onChangeText={field.onChange}
            placeholder="Password"
            secureTextEntry
            error={formState.errors.password?.message}
          />
        )}
      />
      <Button onPress={onSubmit} loading={formState.isSubmitting}>
        Sign In
      </Button>
    </View>
  );
}

What About React Native Web?

React Native Web lets you run React Native code on the web. It sounds perfect for sharing UI. Here's the reality:

┌─────────────────────────────────────────────────────────────────┐
│                    REACT NATIVE WEB TRADEOFFS                    │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  WORKS WELL:                                                    │
│  ├── Simple layouts (View, Text, ScrollView)                   │
│  ├── Basic touchables (Pressable)                              │
│  ├── StyleSheet (compiles to CSS)                              │
│  └── Shared component structure                                 │
│                                                                  │
│  WORKS POORLY:                                                  │
│  ├── Web-specific features (forms, tables, semantic HTML)      │
│  ├── SEO (everything is divs)                                  │
│  ├── Accessibility (ARIA vs native)                            │
│  ├── Performance (extra abstraction layer)                     │
│  ├── Third-party libraries (need RNW-compatible versions)      │
│  └── Bundle size (shipping RN runtime to web)                  │
│                                                                  │
│  DECISION GUIDE:                                                │
│  ├── Mobile-first app that "also" has web → Consider RNW       │
│  ├── Web-first app with mobile → Separate UI packages          │
│  └── Both platforms equally important → Separate UI packages   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

If You Do Use React Native Web

// Package that works on both platforms via RNW

// packages/ui-universal/src/Card.tsx

import { View, Text, StyleSheet, Pressable } from 'react-native';

interface CardProps {
  title: string;
  description: string;
  onPress?: () => void;
}

export function Card({ title, description, onPress }: CardProps) {
  const content = (
    <View style={styles.container}>
      <Text style={styles.title}>{title}</Text>
      <Text style={styles.description}>{description}</Text>
    </View>
  );

  if (onPress) {
    return <Pressable onPress={onPress}>{content}</Pressable>;
  }

  return content;
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
    backgroundColor: '#fff',
    borderRadius: 8,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    // Web-specific shadow
    ...Platform.select({
      web: {
        boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
      },
    }),
  },
  title: {
    fontSize: 18,
    fontWeight: '600',
    marginBottom: 4,
  },
  description: {
    fontSize: 14,
    color: '#666',
  },
});
// apps/web/next.config.js

const { withExpo } = require('@expo/next-adapter');

module.exports = withExpo({
  transpilePackages: ['react-native', 'react-native-web', '@acme/ui-universal'],
  webpack: (config) => {
    config.resolve.alias = {
      ...(config.resolve.alias || {}),
      'react-native$': 'react-native-web',
    };
    return config;
  },
});

Testing Strategy

Testing Shared Code

// packages/core/src/__tests__/auth.test.ts

import { authService } from '../services/auth';
import { useAuthStore } from '../stores/auth';
import { api } from '../api/client';

// Mock the API
jest.mock('../api/client', () => ({
  api: {
    post: jest.fn(),
    get: jest.fn(),
  },
}));

describe('authService', () => {
  beforeEach(() => {
    // Reset store between tests
    useAuthStore.setState({
      user: null,
      accessToken: null,
      isAuthenticated: false,
      isLoading: false,
    });
  });

  describe('login', () => {
    it('should set auth state on successful login', async () => {
      const mockResponse = {
        user: { id: '1', email: 'test@example.com', name: 'Test' },
        accessToken: 'token123',
        refreshToken: 'refresh123',
      };

      (api.post as jest.Mock).mockResolvedValueOnce(mockResponse);

      await authService.login({
        email: 'test@example.com',
        password: 'password123',
      });

      const state = useAuthStore.getState();
      expect(state.user).toEqual(mockResponse.user);
      expect(state.accessToken).toBe('token123');
      expect(state.isAuthenticated).toBe(true);
    });

    it('should throw on invalid credentials', async () => {
      (api.post as jest.Mock).mockRejectedValueOnce(
        new Error('Invalid credentials')
      );

      await expect(
        authService.login({
          email: 'test@example.com',
          password: 'wrong',
        })
      ).rejects.toThrow('Invalid credentials');
    });
  });
});

Testing Platform-Specific Code

// apps/web/src/__tests__/LoginForm.test.tsx

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { LoginForm } from '../components/LoginForm';
import { authService } from '@acme/core';

jest.mock('@acme/core', () => ({
  ...jest.requireActual('@acme/core'),
  authService: {
    login: jest.fn(),
  },
}));

describe('LoginForm (Web)', () => {
  it('should call authService.login on submit', async () => {
    (authService.login as jest.Mock).mockResolvedValueOnce({});

    render(<LoginForm />);

    fireEvent.change(screen.getByPlaceholderText('Email'), {
      target: { value: 'test@example.com' },
    });
    fireEvent.change(screen.getByPlaceholderText('Password'), {
      target: { value: 'password123' },
    });
    fireEvent.click(screen.getByText('Sign In'));

    await waitFor(() => {
      expect(authService.login).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });
});

Quick Reference

What to Share

SHARE (packages/core):
├── API client and configuration
├── Zod schemas and TypeScript types
├── State management (Zustand stores)
├── React Query hooks
├── Business logic services
├── Validation functions
├── Formatting utilities
├── Constants and enums
└── Pure functions

DON'T SHARE:
├── Navigation
├── UI components
├── Gesture handling
├── Native modules
├── Platform-specific APIs
├── Animation libraries
└── Form inputs

Package Dependencies

                    ┌─────────────┐
                    │   @acme/    │
                    │   types     │
                    └──────┬──────┘
                           │
                    ┌──────▼──────┐
                    │   @acme/    │
                    │ ui-shared   │◄── Design tokens only
                    │  (tokens)   │
                    └──────┬──────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
       ┌──────▼──────┐     │     ┌──────▼──────┐
       │   @acme/    │     │     │   @acme/    │
       │   ui-web    │     │     │  ui-native  │
       └──────┬──────┘     │     └──────┬──────┘
              │            │            │
              │     ┌──────▼──────┐     │
              │     │   @acme/    │     │
              └────►│    core     │◄────┘
                    └──────┬──────┘
                           │
              ┌────────────┴────────────┐
              │                         │
       ┌──────▼──────┐           ┌──────▼──────┐
       │  apps/web   │           │apps/mobile  │
       └─────────────┘           └─────────────┘

File Naming Conventions

packages/core/src/
├── api/
│   └── client.ts           # API client
├── hooks/
│   └── useUser.ts          # React hooks (use* prefix)
├── schemas/
│   └── user.ts             # Zod schemas + types
├── services/
│   └── auth.ts             # Business logic
├── stores/
│   └── auth.ts             # Zustand stores
├── utils/
│   └── format.ts           # Pure utility functions
└── index.ts                # Public exports

Closing Thoughts

The goal isn't maximum code sharing. It's maximum productivity with minimum frustration.

Share what shares well:

  • Business logic that doesn't know about UI
  • Types and validation that define contracts
  • State management that's platform-agnostic
  • Utilities that are pure functions

Keep separate what should be separate:

  • UI components that feel native to each platform
  • Navigation that uses platform conventions
  • Gestures and animations that leverage platform capabilities

The monorepo structure enables sharing without forcing it. You can start with separate implementations and extract to shared packages when patterns emerge. That's usually better than prematurely abstracting and fighting the abstraction later.

The best cross-platform code is the code that doesn't know it's cross-platform.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.