Sharing code between web and mobile
If you're building the same product across web and mobile, you'll quickly realize that rewriting logic for every platform drains time and introduces bugs. Sharing code between web and mobile doesn't mean using one UI framework everywhere—it means extracting the parts that actually matter: your business logic, API communication, and state management. In this post, I'll walk you through what to share, what to keep separate, and how a monorepo makes it all work smoothly.
Why sharing code matters
Over the past few years, I've watched teams build the same validation rules, API clients, and data-fetching hooks three times over: once for web, once for React Native, once for Flutter. Each time, inconsistencies crept in. A date validation bug fixed in the web client wouldn't make it to mobile. A new API endpoint would be integrated differently on each platform. The cost of maintaining multiple codebases grows exponentially.
The key insight: share logic, not UI. Your React components live in web. Your React Native screens live in mobile. But the code that talks to your API, validates user input, and manages state? That lives in a shared layer.
Share API clients, types and validation
Your API client is the first and easiest thing to share. Whether you're calling endpoints from a browser or a mobile app, the HTTP mechanics are identical. The difference is the HTTP library—but you can abstract that away.
I typically create a packages/api-client package that exports a thin, platform-agnostic HTTP client:
// packages/api-client/src/http.ts
export interface HttpClient {
get<T>(url: string, options?: RequestOptions): Promise<T>;
post<T>(url: string, body: unknown, options?: RequestOptions): Promise<T>;
put<T>(url: string, body: unknown, options?: RequestOptions): Promise<T>;
delete<T>(url: string, options?: RequestOptions): Promise<T>;
}
export interface RequestOptions {
headers?: Record<string, string>;
timeout?: number;
retry?: boolean;
}
Then build platform-specific implementations. On web, use axios or fetch. On React Native, use axios (same API!) or native fetch. The consumer doesn't care.
// packages/api-client/src/implementations/axios-client.ts
import axios, { AxiosInstance } from 'axios';
import { HttpClient, RequestOptions } from '../http';
export class AxiosHttpClient implements HttpClient {
private client: AxiosInstance;
constructor(baseURL: string) {
this.client = axios.create({ baseURL });
}
async get<T>(url: string, options?: RequestOptions): Promise<T> {
const response = await this.client.get<T>(url, {
headers: options?.headers,
timeout: options?.timeout,
});
return response.data;
}
async post<T>(url: string, body: unknown, options?: RequestOptions): Promise<T> {
const response = await this.client.post<T>(url, body, {
headers: options?.headers,
timeout: options?.timeout,
});
return response.data;
}
}
Now share your TypeScript types and generated API types. If you use OpenAPI or GraphQL, generate types once and import them everywhere:
// packages/api-client/src/types/index.ts
export interface User {
id: string;
email: string;
name: string;
createdAt: string;
}
export interface CreateUserRequest {
email: string;
name: string;
password: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: User;
}
Validation is even more critical. Write your validation rules once using a shared schema library like Zod or Yup:
// packages/validation/src/schemas.ts
import { z } from 'zod';
export const emailSchema = z.string().email('Invalid email address');
export const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[0-9]/, 'Password must contain a number');
export const createUserSchema = z.object({
email: emailSchema,
name: z.string().min(1, 'Name is required'),
password: passwordSchema,
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
Import these schemas in your web forms and mobile screens. A user can't submit invalid data on either platform. Real-time validation? Same rules.
Share hooks for data fetching
Custom hooks are React-specific, but the pattern behind them—managing async state, caching, retries—isn't. You can export utility functions that both web and mobile hooks wrap.
Create a packages/data-fetching that exports platform-agnostic logic:
// packages/data-fetching/src/use-query.ts
export interface UseQueryOptions<T> {
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
retry?: number;
cacheTime?: number;
}
export interface QueryState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export async function fetchWithRetry<T>(
fn: () => Promise<T>,
retries: number = 3
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (i < retries) {
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
}
throw lastError;
}
Now both web and mobile hooks use this foundation:
// apps/web/src/hooks/use-user.ts
import { useState, useEffect } from 'react';
import { fetchWithRetry } from '@myapp/data-fetching';
import { User } from '@myapp/api-client';
export function useUser(userId: string) {
const [state, setState] = useState<{ data: User | null; loading: boolean; error: Error | null }>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
fetchWithRetry(() => apiClient.get<User>(`/users/${userId}`))
.then(data => setState({ data, loading: false, error: null }))
.catch(error => setState({ data: null, loading: false, error }));
}, [userId]);
return state;
}
// apps/mobile/src/hooks/useUser.ts
import { useState, useEffect } from 'react';
import { fetchWithRetry } from '@myapp/data-fetching';
import { User } from '@myapp/api-client';
export function useUser(userId: string) {
const [state, setState] = useState<{ data: User | null; loading: boolean; error: Error | null }>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
fetchWithRetry(() => apiClient.get<User>(`/users/${userId}`))
.then(data => setState({ data, loading: false, error: null }))
.catch(error => setState({ data: null, loading: false, error }));
}, [userId]);
return state;
}
The hooks look identical because the logic is identical. The only difference: which package they import from. If you use Zustand or Redux, share the store definitions and selectors instead.
Keep UI platform-specific
This is the hardest part for teams to accept. Your React components should never be imported into React Native and vice versa. Your design system, spacing, animations, and interaction patterns differ between platforms for good reasons.
Web might use a modal dropdown for date selection. Mobile uses a native picker. Both call the same underlying state management and validation, but the UI is completely different:
// apps/web/src/components/date-input.tsx
import { useState } from 'react';
export function DateInput({ value, onChange, error }: Props) {
return (
<div>
<input
type="date"
value={value}
onChange={e => onChange(new Date(e.target.value))}
aria-invalid={!!error}
/>
{error && <span className="error">{error}</span>}
</div>
);
}
// apps/mobile/src/components/date-input.tsx
import { useState } from 'react';
import DateTimePicker from '@react-native-community/datetimepicker';
import { View, TouchableOpacity, Text } from 'react-native';
export function DateInput({ value, onChange, error }: Props) {
const [show, setShow] = useState(false);
return (
<View>
<TouchableOpacity onPress={() => setShow(true)}>
<Text>{value.toDateString()}</Text>
</TouchableOpacity>
{show && (
<DateTimePicker
value={value}
mode="date"
onChange={(event, selectedDate) => {
setShow(false);
if (selectedDate) onChange(selectedDate);
}}
/>
)}
{error && <Text style={{ color: 'red' }}>{error}</Text>}
</View>
);
}
Both components manage state differently, use different libraries, and feel native. That's the point. Your shared logic handles the data; your platform-specific UI makes it delightful.
A monorepo makes sharing easy
Without a monorepo, sharing code means publishing packages to npm, managing versions, and dealing with private registries. With a monorepo (Turborepo, Nx, or Pnpm workspaces), you simply import from sibling packages:
| Structure | Benefit |
| packages/api-client | Shared HTTP client, types, and endpoints |
| packages/validation | Shared Zod schemas |
| packages/data-fetching | Shared async utilities and query logic |
| apps/web | Next.js web application |
| apps/mobile | React Native or Expo app |
Your package.json dependencies become:
{
"dependencies": {
"@myapp/api-client": "workspace:*",
"@myapp/validation": "workspace:*",
"@myapp/data-fetching": "workspace:*"
}
}
One change in packages/validation? Both apps use it immediately—no npm publish, no version bump dance. Refactoring becomes low-friction. You can update your API client in one place, test it across both apps, and deploy with confidence.
Common mistakes
- Sharing too much UI code. Teams often try to use the same component libraries across platforms. This fails because React and React Native have fundamentally different rendering models. Share business logic, keep UI separate.
- Not extracting common logic early. It's tempting to build web first, then React Native, copying code as you go. Extract shared layers from day one. It costs less upfront and compounds over time.
- Over-abstracting platform differences. Trying to create a unified API client that works identically on all platforms can lead to leaky abstractions. It's okay if mobile makes slightly different network calls. Make the shared layer simple.
- Treating the monorepo as optional. Without proper tooling (Turborepo, npm workspaces), sharing becomes painful. Invest in monorepo infrastructure early or accept that you'll maintain separate packages.
- Ignoring platform-specific caching strategies. Web and mobile have different storage, memory, and network constraints. A generic caching strategy might work, but mobile apps often need platform-specific optimizations like SQLite or AsyncStorage.
Key takeaways
- Share business logic (API clients, validation, data fetching), but keep UI completely separate.
- Use TypeScript interfaces and types to define contracts between shared and platform-specific code.
- Write validation once with Zod or Yup, import it everywhere, and enforce consistency across all platforms.
- Custom hooks are React-specific, but the utilities they build on can be shared across frameworks.
- A monorepo (Turborepo, Nx, or Pnpm workspaces) makes sharing frictionless and refactoring safe.
- Start by sharing the API client. It's the easiest win and pays dividends immediately.






Comments
Loading comments…