React Native and React Web: Sharing Code Without Sharing Pain
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
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?