I've been building web apps for a while. When I picked up React Native for the first time through GSSoC, I assumed the transition would be straightforward. Same language, same component model, similar ecosystem. I was wrong in ways I didn't expect.
Here are the specific places where my web-first mental model broke down — and what I learned by building past each one.
The First Surprise: There's No DOM
This sounds obvious. Of course there's no DOM — it's mobile. But what "no DOM" actually means in practice took me longer to internalise than I expected.
In React Native, you're not writing HTML. You're composing native UI components — View, Text, ScrollView, FlatList, TouchableOpacity. These aren't abstractions over HTML; they compile to actual native components on the platform. An iOS Text becomes a UILabel. An Android View becomes a android.view.View.
The implications cascade:
- CSS doesn't exist. You use JavaScript style objects with a CSS-like subset via
StyleSheet.create(). - Flexbox is the only layout model. There's no grid, no block display, no inline.
- There's no semantic HTML. No
<nav>, no<section>, no<button>. Accessibility is opt-in, not structural.
// The web way
<div className="flex items-center gap-2">
<img src={avatar} className="rounded-full w-8 h-8" />
<span>{name}</span>
</div>
// The React Native way
<View style={styles.container}>
<Image source={{ uri: avatar }} style={styles.avatar} />
<Text style={styles.name}>{name}</Text>
</View>
const styles = StyleSheet.create({
container: { flexDirection: "row", alignItems: "center", gap: 8 },
avatar: { width: 32, height: 32, borderRadius: 16 },
name: { fontSize: 14, fontWeight: "500" },
});
I got over this in a week. It's different, not harder.
The Second Surprise: Routing is a Different Paradigm
On the web, routing is URL-based and stateless. Every route is a URL, navigation is reversible via the browser's back button, and deep linking is free — you just share a URL.
On mobile, routing is stack-based. Navigating to a new screen pushes it onto a stack. Going "back" pops the stack. This creates a fundamentally different UX contract with the user: they expect swipe-to-go-back gestures, they expect the previous screen to persist in memory while they're on the current one, and they don't think in terms of URLs.
Expo Router (file-based routing for React Native, similar to Next.js App Router) is excellent and dramatically reduces this friction:
app/
├── (tabs)/
│ ├── index.tsx # /
│ ├── profile.tsx # /profile
│ └── explore.tsx # /explore
├── auth/
│ ├── login.tsx
│ └── signup.tsx
└── modal.tsx # Presented as a modal on top of any route
But even with Expo Router, you have to think about tab navigators, stack navigators, and modal presentations — navigation primitives that don't exist on the web.
// Presenting a screen as a modal (not a full navigation push)
router.present("/modal");
// Going back to a specific point in the stack
router.dismissAll();
router.push("/(tabs)/");
The mental model shift: on mobile, navigation is gestural and spatial, not URL-addressable and stateless. Design your navigation structure before you write a single component.
Auth Flows Are More Complex
On the web, a Clerk auth redirect is two lines and a middleware function. On mobile, there's no HTTP-only cookie, there's no middleware, and the OAuth redirect flow requires a custom URL scheme to bring the user back into the app.
With Clerk's Expo SDK:
// expo-auth-session handles the OAuth redirect back into the app
import * as AuthSession from "expo-auth-session";
import { useOAuth } from "@clerk/clerk-expo";
export function GoogleSignInButton() {
const { startOAuthFlow } = useOAuth({ strategy: "oauth_google" });
const handleSignIn = useCallback(async () => {
try {
const { createdSessionId, setActive } = await startOAuthFlow({
redirectUrl: AuthSession.makeRedirectUri({ scheme: "myapp" }),
});
if (createdSessionId) {
setActive!({ session: createdSessionId });
}
} catch (err) {
console.error("OAuth error", err);
}
}, []);
return (
<TouchableOpacity onPress={handleSignIn}>
<Text>Sign in with Google</Text>
</TouchableOpacity>
);
}
You also need to configure your app.json with the custom URL scheme, register the scheme with Clerk's dashboard as a valid redirect URI, and handle deep link routing when the OAuth provider redirects back.
It's not hard once you understand the flow — but if you approach it with web assumptions, you'll be confused for hours.
Async Storage Instead of Cookies
Mobile apps don't have cookies. Persistent state — auth tokens, user preferences, cached data — lives in AsyncStorage (or a more capable alternative like expo-secure-store for sensitive data).
import * as SecureStore from "expo-secure-store";
// Store a token securely (encrypted on device, hardware-backed where available)
await SecureStore.setItemAsync("auth_token", token);
// Retrieve it
const token = await SecureStore.getItemAsync("auth_token");
For auth tokens specifically, expo-secure-store is the right choice: it uses the device's secure enclave where available. Never store tokens in AsyncStorage — it's not encrypted.
Native APIs: The Superpower You Don't Have on the Web
The flip side of the complexity is access to APIs that simply don't exist in a browser:
// Camera — capture and process images natively
import { CameraView, useCameraPermissions } from "expo-camera";
// Push notifications — delivered by APNs and FCM directly
import * as Notifications from "expo-notifications";
// Haptics — physical feedback that makes interactions feel real
import * as Haptics from "expo-haptics";
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
// Accelerometer, gyroscope, barometer — sensor data
import { Accelerometer } from "expo-sensors";
// Contacts, calendar, health data (with permission)
import * as Contacts from "expo-contacts";
When I added haptic feedback to the submission button in a mobile project — a tiny thing, two lines of code — the interaction instantly felt like a real app rather than a web page. That delta is real.
Performance: Think in FlatList, Not Maps
On the web, rendering a list of 200 items with .map() is fine — React reconciles the virtual DOM efficiently, the browser handles layout, and users rarely notice.
On mobile, .map() over a large list renders every item at once and destroys scroll performance. FlatList renders only the visible items (plus a configurable window) and recycles components as the user scrolls:
// Don't do this on mobile for large lists
{items.map(item => <ItemCard key={item.id} item={item} />)}
// Do this
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ItemCard item={item} />}
initialNumToRender={10}
maxToRenderPerBatch={5}
windowSize={5}
/>
The performance difference on a mid-range Android device between these two approaches can be the difference between a smooth 60fps scroll and a janky, frame-dropping mess.
What I'd Tell My Past Self
The React Native knowledge transfer from React web is real and significant — components, state management, hooks, TypeScript, the entire ecosystem. But the platform assumptions are different.
Mobile users swipe, tap, scroll with their thumbs, use apps in short sessions, and have completely different expectations about navigation and feedback than web users. Respecting those expectations isn't extra work — it's what separates apps that feel native from apps that feel like web pages squeezed into a phone screen.
Learn the platform you're building for, not just the language.