8 June 2026·Engineering·5 min read · 850 words

A web developer's notes on React Native.

React Native isn't just React on a phone. But it's closer than I expected, and stranger than I expected, often at the same time.

I came to React Native from several years of web React. The mental model felt familiar enough that I expected a shallow learning curve. Same component tree, same hooks, same TypeScript. In some ways that holds. In other ways, the familiarity is almost a trap.

These are notes from building a real app. Not a tutorial project, not a todo list. A live match-tracking app for amateur soccer, used in an actual game. That context matters, because things break differently when someone is standing on a pitch waiting for the timer to work.

What transfers

A lot of web React knowledge moves across cleanly. Components, hooks, TypeScript, state management patterns. The component model is close enough that you're writing familiar-looking code from the first hour.

What doesn't transfer is the environmental layer. On the web, you mostly assume a stable runtime. The DOM is the DOM. In React Native, the runtime is Expo, which sits on top of a native layer, which has its own versioning. The whole stack can disagree with itself in ways that are hard to trace.

I scaffolded on SDK 56, then discovered the App Store version of Expo Go only supports SDK 54. The downgrade was fine. What wasn't fine: after the clean install, one dependency ended up nested instead of hoisted, which silently broke TypeScript's type resolution for it. The fix was adding it as an explicit top-level dependency. Not something a web instinct would point you toward.

The platform has more opinions than a browser does, and those opinions interact.

On styling

On the web, I'd settled on Chakra UI as my default. Component library, design tokens, accessible primitives out of the box. The styling question was already answered.

React Native doesn't have that settled answer. The ecosystem has options: NativeWind brings Tailwind utility classes across, Tamagui and Gluestack sit closer to the component library end. But the landscape feels less legible from the outside. Nothing maps cleanly from what I was used to.

I tried NativeWind briefly. It didn't install cleanly against the other dependencies and I didn't push hard to make it work. I landed on React Native's built-in StyleSheet and stayed there.

It's more verbose than utility classes, closer to CSS-in-JS in feel. But it works without ceremony. The layout engine is a stripped-down Flexbox applied to a more constrained surface than the web. Fewer affordances, fewer surprises.

What I don't have yet is a strong opinion on what's actually idiomatic in React Native styling. On the web, that argument has been had many times over. Here it still feels open to me.

App lifecycle

The match timer worked in tests. It worked on the simulator. It broke in the first real match, when the app was backgrounded between halves and the counter had stopped ticking without knowing it stopped.

Mobile apps have a lifecycle that web pages don't. A browser tab might lose focus, but it keeps running. An iOS app that moves to the background can have its execution paused. Any time-dependent state that was counting forward just... stops.

It's not a subtle trap. But it's one a web developer might not think to reach for, because the condition that exposes it doesn't exist in the same way on the web. The simulator doesn't help either. It doesn't replicate backgrounding the way a real device does.

The fix was switching to absolute timestamps and computing elapsed time as now - segmentStart on each render. There's nothing to drift, nothing to pause.

ID generation

3 attempts. First, uuid: it references import.meta internally, which Metro doesn't support on web targets. Then crypto.randomUUID(): not a reliable global in Hermes on Expo Go, causing a runtime crash on device. Finally, expo-crypto.randomUUID() wrapped in a thin utility file.

The solution isn't the interesting part. What is: something as mundane as generating a unique ID depends on which JS engine is running, on which runtime, in which environment. Web developers take a stable JS environment largely for granted. React Native doesn't offer that.

The right move is wrapping these things early. One abstraction, one call site, one place to change.

Services layer

The architecture I settled on: screens handle rendering, services handle logic, stores hold state. Nothing clever.

Having that separation from the start meant bugs were fixable without touching the UI, and tests didn't require mocking hooks. Services are plain TypeScript functions with no React in sight.

Mobile development made the cost of blurring logic and rendering more visible than web work usually does, probably because more things can go wrong at the runtime level. You want the diagnostic surface to be small.

Still ahead

Navigation across tabs had its own friction. React Navigation scopes the navigation prop to its own stack, so crossing from one tab's stack to another requires a different type entirely. It's not hard once you know it.

There's a whole layer I haven't reached: native modules, CI pipelines, TestFlight, app store distribution. That's where mobile development genuinely diverges from web work, and where the cross platform promise gets most tested.

I'm curious to find out where it holds.