← All writing

Technical essay · Mobile systems

the better I got at React Native, the less I thought in React Native

Language is an implementation detail. Interaction primitives are not.

I have spent most of my mobile career working with React Native. That sentence is useful to a recruiter and almost useless as a description of how I think about mobile systems.

React Native is a means of implementation. It is not the source of the interaction model, and it does not relieve the engineer of understanding the systems underneath it. The product still runs on iOS and Android. Its views participate in native layout. Its gestures compete inside native recognition systems. Its media moves through platform decoders and audio sessions. Its credentials live under Keychain and Keystore policy. Its process is suspended, resumed, interrupted, reclaimed, and occasionally killed according to rules that exist independently of JavaScript.

The user does not experience TypeScript, Swift, Kotlin, or C++. They experience whether the composer remains attached to the keyboard, whether a drag can be interrupted without the interface losing itself, whether media continues coherently into fullscreen, whether biometric fallback behaves like the rest of the operating system, and whether returning to an application restores something recognisable.

Those behaviours are determined by interaction primitives long before they are expressed in a particular language.

The wrong first question

A common way to approach mobile development is to begin with the framework:

We are building this in React Native. How do we implement this interaction?

The question narrows the search too early. It encourages us to look for a package, component, hook, or JavaScript technique before deciding what the experience actually requires.

Suppose the product needs a messaging surface where the composer follows the keyboard, the thread remains correctly anchored as messages arrive, interactive keyboard dismissal tracks the user's finger, and a sent message appears to leave the composer before joining the thread.

None of that begins as a React Native requirement.

The experience implies a set of behaviours:

  • The keyboard and composer must participate in the same continuous movement.
  • Focus, safe-area insets, and the visible message region must agree about the available space.
  • The thread needs a defined anchoring policy when content is inserted.
  • Interactive dismissal needs an owner.
  • Sending introduces a temporary visual state that must survive latency, failure, and interruption.
  • Accessibility focus must remain coherent while the visual hierarchy changes.

Whether the eventual implementation uses React Native, SwiftUI, UIKit, Jetpack Compose, or Flutter does not alter those obligations. The language changes how the system is expressed. It does not change what the system must understand.

The intended experience should determine the primitives. The implementation should follow from that decision.

What I mean by a primitive

I am not using “primitive” as a euphemism for writing everything at the lowest possible level.

A primitive is a durable behaviour or capability that the operating system, rendering system, or interaction model already understands. Scroll containers, gesture recognisers, focus, view-controller presentation, keyboard coordination, accessibility nodes, media sessions, secure credentials, animation clocks, background execution, and application lifecycle are all examples.

An interaction primitive is also more than a component. A bottom sheet is a component. Drag ownership, velocity, snapping, interruption, occlusion, focus containment, and dismissal are the primitives that determine whether the sheet behaves correctly.

A video player is a component. Playback ownership, buffering, audio-session policy, surface attachment, lifecycle, orientation, and the transfer between inline and fullscreen presentation are the primitives that determine whether it feels like one continuous piece of media.

A biometric button is a component. User presence, permitted authenticators, lockout, credential fallback, key accessibility, cancellation, and cryptographic signing are the primitives that determine what authentication actually means.

Components can be replaced. Libraries can be replaced. Languages can be replaced. These behavioural contracts remain because the user and the operating system continue to expect them.

Abstraction does not repeal the platform

React Native is productive because it gives us a strong shared layer for product logic, composition, state, and interface development. The mistake is interpreting that productivity as evidence that the platform beneath it has become irrelevant.

An abstraction can give several platforms a common vocabulary. It cannot make their underlying contracts identical.

Consider biometric authentication.

On iOS, requesting biometric-only authentication and requesting device-owner authentication are materially different policies. One may fail when biometrics are unavailable or locked out; the other can permit the device passcode as a system-managed fallback. Keychain access-control policy determines whether a protected credential remains usable after biometric enrolment changes and whether it can leave the device.

Android expresses related decisions through BiometricPrompt, permitted authenticator combinations, and Keystore policy. Allowing DEVICE_CREDENTIAL changes the prompt contract and removes certain application-controlled fallback behaviour. Hardware availability, enrolment, lockout, and key invalidation do not map perfectly onto iOS.

It is still reasonable to expose a coherent TypeScript API over both platforms. I have done exactly that. But the API should coordinate the differences without pretending they do not exist.

A method named authenticate() cannot decide what authentication means. The product has to decide whether system credentials are acceptable, how lockout should recover, what happens when biometric enrolment changes, whether a private key must be hardware-backed, and what the application should learn from failure. Swift and Kotlin then implement those policies through the primitives their platforms provide. TypeScript gives the rest of the product a stable contract.

That is a useful abstraction. An abstraction that flattens every platform state into true, false, or an unstructured error has only concealed the system the application still depends on.

React Native as an orchestration layer

I treat React Native as an orchestration layer over real platform systems.

That means using it confidently where the abstraction holds and descending into native code where ownership, fidelity, performance, or security requires it. TypeScript can own shared product rules and composition. Swift can own an iOS capability whose truth lives inside an Apple framework. Kotlin can do the same for Android. C++ may belong in a media or image-processing path where moving each frame through JavaScript would be structurally wrong.

The specific boundary changes with the problem.

Fabric, JSI, TurboModules, and Nitro Modules make these boundaries cheaper and more expressive. They improve how React Native participates in native systems. They do not identify the correct native system on our behalf.

A faster bridge to the wrong primitive still produces the wrong interaction.

This is why I do not consider “avoiding native code” evidence of React Native expertise. The more useful skill is knowing where the shared abstraction remains honest, where the platform has begun leaking through it, and whether that leak should be repaired, exposed, or accepted as deliberate divergence.

Sometimes the correct implementation is almost entirely React Native. Sometimes JavaScript coordinates a thin native capability. Sometimes the interaction belongs natively and React Native should only observe its state. The decision should follow ownership, not ideology.

The framework-first failure mode

Framework-first development tends to fail gradually.

A team begins with the nearest cross-platform component. It works on the happy path. The keyboard behaves incorrectly on one platform, so an offset is introduced. The offset fails on devices with different insets, so a listener is added. Animation state is mirrored into JavaScript. Lifecycle handling arrives through an effect. Cancellation creates another state flag. The same interaction now has several partial owners, each responding slightly later than the system it is attempting to imitate.

The final interface may look correct in a still image. It remains unstable under interruption.

This is particularly visible in gestures and animation. A native interaction does not end simply because the animation reached its target value. The user can reverse direction, add another finger, background the application, trigger navigation, rotate the device, or interrupt one gesture with another. An interaction that only defines its ideal completion path has not defined its behaviour.

The same problem appears in lists. A feed is not made fast by applying memoisation until a profiler becomes quiet. Virtualisation, row ownership, media lifetime, measurement, subscription topology, image loading, and interaction feedback determine whether the list remains cheap as a system. A row that secretly subscribes to global state can defeat an otherwise sensible list architecture. A video that keeps expensive resources after leaving the viewport can do the same.

These problems eventually surface inside React Native, but they are not fundamentally React Native problems. They are failures to model the primitives and their ownership correctly.

Cross-platform does not require behavioural sameness

A shared product should feel like the same product across platforms. That does not mean every interaction must be mechanically identical.

iOS and Android have different navigation conventions, back behaviour, permission models, text systems, credential policies, accessibility expectations, and lifecycle details. Flattening these differences can produce a consistent codebase while making both applications feel foreign.

The better target is shared product intent with platform-correct execution.

A biometric flow can preserve the same security policy while presenting the fallback each operating system expects. A navigation action can reach the same product destination while respecting the native back model. A media surface can preserve playback continuity while using different presentation mechanisms. A gesture can express the same relationship without forcing both platforms through an implementation that naturally belongs to only one.

This requires a more deliberate shared contract. The product must decide which behaviours are invariant and which are allowed to diverge.

“Cross-platform” should describe the reach of the product. It should not prevent the product from understanding the platforms it reaches.

Start with the experience

My implementation process increasingly begins without framework nouns.

I first describe what must remain true from the user's perspective.

For a feed, that might include scroll continuity, immediate touch response, stable content identity, cheap offscreen behaviour, and coherent media ownership.

For a composer, it might include focus continuity, keyboard attachment, explicit send state, recoverable failure, and correct thread anchoring.

For authentication, it might include user presence, hardware-backed key use, deliberate fallback, stable lockout behaviour, and a clear distinction between cancellation and failure.

Once the intended experience is clear, I work through six questions:

  1. What are the states and transitions?

    This includes interruption, cancellation, recovery, backgrounding, and re-entry—not only successful completion.

  2. Who owns each state?

    The operating system, a native view, the React tree, a server, or some temporary coordinator should not all believe they are authoritative.

  3. Which platform primitives already express the required behaviour?

    This is where UIKit, Android framework APIs, media systems, gesture recognisers, secure hardware, accessibility, and lifecycle become part of product design.

  4. Which behaviours are genuinely shared?

    Shared product policy belongs in the common contract. Platform mechanics should not be forced into sameness merely to improve code reuse.

  5. Where should the implementation boundary sit?

    Only now is it useful to decide what belongs in TypeScript, Swift, Kotlin, C++, a native module, or an existing library.

  6. What evidence would prove the interaction works?

    A named user scenario, explicit state events, platform-specific expectations, and interruption cases are more useful than an isolated screenshot or a green automation row.

This ordering does not always produce more native code. It often prevents unnecessary native code because the actual responsibility becomes clearer. What it consistently produces is a more defensible boundary.

Language still matters

Calling language an implementation detail does not mean language is inconsequential.

Languages affect memory safety, concurrency, performance, available tooling, build systems, debugging, hiring, maintainability, and how easily an engineer can access the platform. A poor language or runtime boundary can make a correct design expensive or impractical.

The distinction is about order.

Language should be chosen in service of a system whose obligations are already understood. It should not be allowed to define those obligations by accident.

If an interaction requires direct participation in an iOS media pipeline, Swift or Objective-C may be the appropriate implementation language. If the work involves a performance-sensitive cross-platform processing core, C++ may be justified. If the behaviour is ordinary product composition over stable native components, TypeScript and React Native may be the best place for nearly all of it.

These are engineering decisions rather than declarations of allegiance.

I still choose React Native often. It gives me a productive shared surface while preserving the ability to follow a problem into the operating system, native code, backend services, or performance instrumentation. Its value is not that it makes the platforms disappear. Its value is that, used properly, it lets the product share what should be shared without giving up access to what cannot be abstracted honestly.

What React Native expertise eventually becomes

Early React Native knowledge is necessarily framework-shaped. Components, hooks, navigation, styling, state, libraries, builds, and release tooling are how the work begins.

The deeper progression leads back toward the platform.

A strong React Native engineer should understand how a React render becomes native work, how layout and measurement cross the boundary, where gesture recognition runs, which thread owns animation, how native modules schedule work, how memory behaves across runtimes, how the application lifecycle affects outstanding operations, and how platform capabilities should be represented without destroying their semantics.

This does not reduce the importance of React Native. It places it accurately.

The framework becomes one part of a larger mobile system. The engineer becomes capable of following the interaction far enough to find its real owner.

When I encounter a new mobile problem now, I rarely begin by asking how to build it in React Native. I describe what must remain true while the user touches, drags, backgrounds, interrupts, retries, and returns. I identify which parts of iOS and Android already own those truths. Only then do I decide what TypeScript should coordinate, what Swift or Kotlin should implement, and whether another runtime belongs in the path.

That makes the framework less central to the explanation of the work. It makes the engineering considerably more demanding.

It also produces a product that behaves as though it understands the device it is running on.