React Native vs React Web: Sharing Code Between Platforms (2026)

React and React Native share the same component model, hooks API, and state management libraries — but they have different primitives (<div> vs <View>, CSS vs StyleSheet). With the right architecture you can share 60–80% of code between web and mobile: all business logic, custom hooks, API calls, state stores and validation are platform-agnostic. Only the presentation layer needs platform-specific implementations.

What Can Be Shared

CategoryShareable?Notes
Business logic✅ 100%Pure functions, calculations
Custom hooks✅ 100%useCart, useAuth, useForm
API calls / fetching✅ 100%fetch works on both platforms
State management✅ 100%Zustand, Jotai, Redux Toolkit
Validation✅ 100%Zod schemas
TypeScript types✅ 100%Shared type definitions
UI components⚠️ PartialNeed platform variants or universal lib
Navigation⚠️ PartialExpo Router or Solito abstracts it
Styling❌ Platform-specificCSS vs StyleSheet (unless using Tamagui)
Native APIs❌ Platform-specificCamera, GPS need Expo modules

Shared Custom Hooks

// packages/shared/src/hooks/useAuth.ts
// This hook works identically on web and native
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface AuthState {
  user: User | null
  token: string | null
  login: (credentials: { email: string; password: string }) => Promise<void>
  logout: () => void
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      login: async (credentials) => {
        const { user, token } = await authApi.login(credentials)
        set({ user, token })
      },
      logout: () => set({ user: null, token: null }),
    }),
    {
      name: 'auth-storage',
      // Platform-agnostic storage — pass AsyncStorage on RN, localStorage on web
      storage: createJSONStorage(() => getPlatformStorage()),
    }
  )
)

// packages/shared/src/hooks/useCart.ts
// Platform-agnostic cart logic — works on web and RN
export function useCart() {
  const [items, setItems] = useState<CartItem[]>([])

  const addItem = (product: Product) => {
    setItems(prev => {
      const existing = prev.find(i => i.id === product.id)
      if (existing) return prev.map(i => i.id === product.id ? { ...i, qty: i.qty + 1 } : i)
      return [...prev, { ...product, qty: 1 }]
    })
  }

  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0)
  return { items, addItem, total }
}

// packages/shared/src/api/products.ts
// fetch works everywhere — same file on web and RN
export async function fetchProducts(category?: string): Promise<Product[]> {
  const url = `${API_BASE}/products${category ? `?category=${category}` : ''}`
  const res = await fetch(url, { headers: getAuthHeaders() })
  return res.json()
}

Platform-Specific Files

// React Native resolves platform-specific files automatically
// Button.tsx         ← fallback (neither web nor native)
// Button.web.tsx     ← used on web
// Button.native.tsx  ← used on iOS and Android
// Button.ios.tsx     ← iOS only
// Button.android.tsx ← Android only

// packages/ui/src/Button/Button.web.tsx
export function Button({ label, onPress, variant = 'primary' }: ButtonProps) {
  return (
    <button
      onClick={onPress}
      className={`btn btn-${variant}`}
      style={{ borderRadius: 50, padding: '10px 24px' }}
    >
      {label}
    </button>
  )
}

// packages/ui/src/Button/Button.native.tsx
import { TouchableOpacity, Text, StyleSheet } from 'react-native'

export function Button({ label, onPress, variant = 'primary' }: ButtonProps) {
  return (
    <TouchableOpacity
      onPress={onPress}
      style={[styles.button, variant === 'primary' ? styles.primary : styles.secondary]}
      activeOpacity={0.8}
    >
      <Text style={styles.label}>{label}</Text>
    </TouchableOpacity>
  )
}

const styles = StyleSheet.create({
  button: { borderRadius: 50, paddingVertical: 12, paddingHorizontal: 24, alignItems: 'center' },
  primary: { backgroundColor: '#6366f1' },
  secondary: { backgroundColor: 'transparent', borderWidth: 1, borderColor: '#6366f1' },
  label: { color: '#fff', fontWeight: '600', fontSize: 14 },
})

Monorepo Structure

my-app/
├── apps/
│   ├── web/              ← Next.js web app
│   │   └── package.json
│   └── mobile/           ← Expo React Native app
│       └── package.json
├── packages/
│   ├── shared/           ← Hooks, API, stores, types, validation
│   │   └── src/
│   │       ├── hooks/
│   │       ├── api/
│   │       ├── stores/
│   │       └── types/
│   └── ui/               ← Platform-split components
│       └── src/
│           ├── Button/
│           │   ├── Button.web.tsx
│           │   ├── Button.native.tsx
│           │   └── index.ts
│           └── Input/
├── turbo.json
└── pnpm-workspace.yaml

# apps/mobile/package.json
{
  "dependencies": {
    "@myapp/shared": "workspace:*",
    "@myapp/ui": "workspace:*",
    "expo": "~51.0.0",
    "react-native": "0.74.0"
  }
}

# apps/web/package.json
{
  "dependencies": {
    "@myapp/shared": "workspace:*",
    "@myapp/ui": "workspace:*",
    "next": "^15.0.0"
  }
}

Solito: Shared Navigation

// Solito unifies React Navigation (RN) and Next.js router (web)
npm install solito

// Shared navigation — same code on web and native
import { useRouter } from 'solito/router'
import { Link } from 'solito/link'

function ProductCard({ product }: { product: Product }) {
  const router = useRouter()

  return (
    <View>
      <Text>{product.name}</Text>
      {/* Link renders <a> on web, <TouchableOpacity> on native */}
      <Link href={`/products/${product.id}`}>
        <Text>View Details</Text>
      </Link>
      <Button label="Add to Cart" onPress={() => router.push('/cart')} />
    </View>
  )
}

Tamagui: Universal UI

// Tamagui — universal design system, same JSX on web and native
// Outputs optimized CSS on web, StyleSheet on native
import { Button, Text, Stack, XStack, YStack } from 'tamagui'

// This component works identically on web and React Native
function ProductCard({ product }: { product: Product }) {
  return (
    <YStack
      backgroundColor="$surface"
      borderRadius="$4"
      padding="$4"
      borderWidth={1}
      borderColor="$borderColor"
      gap="$3"
    >
      <Text fontSize="$6" fontWeight="bold" color="$color">{product.name}</Text>
      <Text color="$colorMuted">{product.description}</Text>
      <XStack justifyContent="space-between" alignItems="center">
        <Text fontSize="$7" color="$purple10">${product.price}</Text>
        <Button size="$3" backgroundColor="$purple8">Add to Cart</Button>
      </XStack>
    </YStack>
  )
}

Expo Router Web

// Expo Router v3+ supports web output — file-based routing for both
// apps/mobile/app/(tabs)/products.tsx — same file serves web and native
import { FlatList, View, Text } from 'react-native'
import { useProducts } from '@myapp/shared/hooks/useProducts'

export default function ProductsScreen() {
  const { data: products } = useProducts()

  return (
    <FlatList
      data={products}
      keyExtractor={p => p.id}
      renderItem={({ item }) => <ProductCard product={item} />}
    />
  )
}

# Build web output
npx expo export --platform web

# metro.config.js — enable .web.tsx resolution
const { getDefaultConfig } = require('expo/metro-config')
const config = getDefaultConfig(__dirname)
config.resolver.sourceExts = ['web.tsx', 'web.ts', 'tsx', 'ts', 'web.js', 'js']
module.exports = config

Key Differences Reference

ConceptReact WebReact Native
Container<div><View>
Text<p>, <span>, <h1><Text> (required)
Image<img src="..."><Image source={{uri:'...'}}>
ScrollCSS overflow: auto<ScrollView>
StylingCSS / TailwindStyleSheet.create({})
ClickonClickonPress
Input<input type="text"><TextInput>
List.map() or virtualized<FlatList> (always virtualized)