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
| Category | Shareable? | 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 | ⚠️ Partial | Need platform variants or universal lib |
| Navigation | ⚠️ Partial | Expo Router or Solito abstracts it |
| Styling | ❌ Platform-specific | CSS vs StyleSheet (unless using Tamagui) |
| Native APIs | ❌ Platform-specific | Camera, 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()
}
// 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
| Concept | React Web | React Native |
| Container | <div> | <View> |
| Text | <p>, <span>, <h1> | <Text> (required) |
| Image | <img src="..."> | <Image source={{uri:'...'}}> |
| Scroll | CSS overflow: auto | <ScrollView> |
| Styling | CSS / Tailwind | StyleSheet.create({}) |
| Click | onClick | onPress |
| Input | <input type="text"> | <TextInput> |
| List | .map() or virtualized | <FlatList> (always virtualized) |