React Native Development UAE: Building Cross-Platform Mobile Apps with Expo, TypeScript & Native Modules
React Native has settled into a comfortable maturity. The tooling questions that dominated a few years ago — Expo or bare, which navigation library, how to handle native code — now have reasonably clear answers. This guide covers those answers and the decisions that still genuinely require thought.
Start With Expo
The old advice was that Expo is fine for prototypes but you will eventually "eject" for real apps. That is no longer true, and repeating it costs teams a lot of unnecessary configuration work.
With config plugins and development builds, Expo supports custom native code. You get over-the-air updates, managed builds, and a large set of well-maintained native modules without hand-editing Xcode project files.
When Bare React Native Is Still Right
- You are adding React Native to a substantial existing native app
- You depend on a native SDK with no Expo config plugin and no appetite to write one
- You need control over the native build at a level that config plugins do not reach
Outside those cases, starting bare means recreating what Expo already solved.
Over-the-air updates let you ship a JavaScript-only fix in minutes instead of waiting on app review. For a bug affecting field staff mid-shift, that difference is operationally significant.
TypeScript: Configure It Strictly
TypeScript is the default for new React Native projects. The value depends almost entirely on how strictly it is configured — a permissive config gives you the syntax overhead without the safety.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true
}
}
noUncheckedIndexedAccess is the one most teams skip and most benefit from — it forces you to handle the case where an array index or record key returns nothing, which is a common source of production crashes.
Type Your API Boundary
Hand-written interfaces for API responses drift from reality. Validate at runtime with a schema library so a backend change surfaces as a clear error instead of an undefined property three components deep.
import { z } from 'zod';
const Order = z.object({
id: z.string(),
status: z.enum(['pending', 'assigned', 'delivered']),
total: z.number(),
assignedTo: z.string().nullable(),
});
type Order = z.infer<typeof Order>;
export async function fetchOrder(id: string): Promise<Order> {
const response = await api.get(`/orders/${id}`);
// Throws with a precise path if the server shape changed.
return Order.parse(response.data);
}
Navigation
React Navigation is the standard. Expo Router builds file-based routing on top of it and is worth adopting for new projects — it gives you deep linking and typed routes with far less boilerplate.
Get Deep Linking Right Early
Deep links are how push notifications open the correct screen, and retrofitting them is unpleasant. Define your URL scheme and universal links at the start, and make every screen reachable by URL.
State Management
The most common architectural mistake in React Native apps is putting server data into a global client store.
Separate Server State From Client State
- Server state — data fetched from an API. It has caching, staleness, refetching, and error concerns. Use TanStack Query or RTK Query. These solve problems you would otherwise reimplement badly
- Client state — UI state, filters, form drafts, the current theme. This is small. Zustand is usually enough; React context is fine for genuinely rare changes
Redux remains a reasonable choice for large apps with complex interdependent state and teams that want strict conventions. For most apps, TanStack Query plus Zustand is less code and easier to follow.
// Server state — cached, refetched, deduplicated for you.
const { data, isPending, error } = useQuery({
queryKey: ['orders', status],
queryFn: () => fetchOrders(status),
staleTime: 30_000,
});
// Client state — small and synchronous.
const useFilters = create<FilterState>((set) => ({
status: 'open',
setStatus: (status) => set({ status }),
}));
Offline Storage
This is where business apps live or die. Choose storage based on what you are storing.
- MMKV — fast key-value storage. Right for tokens, preferences, small cached blobs. Substantially faster than AsyncStorage
- SQLite (op-sqlite, expo-sqlite) — right for relational data and anything you need to query
- WatermelonDB — a sync-oriented layer over SQLite, worth considering if offline sync is central
- SecureStore / Keychain — the only acceptable place for credentials and tokens
Never Put Tokens in AsyncStorage
AsyncStorage is unencrypted. Authentication tokens belong in expo-secure-store or the platform keychain. This is a frequent and avoidable security finding.
Design Sync Before Screens
Decide upfront: what happens when the user edits a record offline that someone else edited on the server? Last-write-wins, server-wins, or explicit conflict resolution? Each is defensible for different data. What is not defensible is discovering you never decided, after a driver's shift report silently vanished.
Push Notifications
expo-notifications covers registration, permissions, and handling across both platforms.
Three things that reliably cause problems:
- Token rotation: Push tokens change. Re-register on every launch and clear on logout, or a user's notifications will keep arriving on a device they signed out of
- Foreground behaviour: By default, notifications do not display while the app is open. Configure a handler and decide deliberately
- Payload size: Send an identifier and let the app fetch the detail. Push is not a reliable data channel
Native Modules
Reach for a native module only after confirming nothing existing covers your need. Every native module is code you now maintain across two platforms and every React Native upgrade.
When you do need one, the Expo Modules API is significantly more approachable than writing bridge code by hand — Swift on iOS, Kotlin on Android, with a typed JavaScript interface generated for you. Package it as a config plugin so the native configuration stays reproducible from a clean checkout.
Performance
Most React Native performance complaints trace back to a handful of causes.
Lists
This is the most common one. Use FlashList or a properly configured FlatList — never map() over a large array inside a ScrollView, which mounts every row at once. Keep row components memoised and their props stable.
Re-renders
An inline object or arrow function in props breaks memoisation on every render. Use the React Compiler if you are on a version that supports it; otherwise be deliberate with useCallback and useMemo in list rows specifically, rather than scattering them everywhere.
Animations
Run animations on the UI thread with Reanimated. Anything driven from JavaScript will stutter when the JS thread is busy — which is exactly when the user is doing something.
Images
Use expo-image for caching and efficient decoding, and serve appropriately sized images from the server. Downloading a 4000px image to display it at 300px wastes bandwidth and memory.
Startup
Hermes is the default engine and should stay on. Defer non-critical initialisation — analytics, feature flags, remote config — until after first paint.
Testing
- Unit: Jest for logic — sync reconciliation, formatters, validation. Highest value per minute spent
- Component: React Native Testing Library, asserting on what users see rather than internal state
- End-to-end: Maestro is markedly simpler to write and maintain than the alternatives. Cover the two or three flows that must never break
Chasing a coverage percentage on a mobile app is rarely a good use of time. Test the sync logic, the money calculations, and the critical path.
Building and Releasing
EAS Build handles signing and produces store-ready binaries without a local native toolchain. EAS Submit uploads them.
A Workable Release Setup
- Separate development, preview, and production build profiles with distinct bundle identifiers so they coexist on one device
- OTA updates for JavaScript-only fixes; a store build whenever native code or dependencies change
- Crash reporting with source maps uploaded — a minified stack trace is close to useless
- Staged rollouts on Play, so a bad release can be halted
Know What OTA Cannot Do
Over-the-air updates ship JavaScript and assets only. Anything touching native code requires a new binary. Shipping a JS update that expects a native module the installed binary does not have will crash on launch — gate on runtime version.
Where This Meets Power Platform
If your organisation runs on Microsoft, the honest framing is that React Native and Power Apps are not competitors — they answer different questions.
Internal workflows for licensed users are usually faster and cheaper as canvas apps, and stay easier to change. React Native earns its place when the app is customer-facing, needs deep hardware access, or has offline requirements beyond what canvas offline handles. A common and effective split is Dataverse as the system of record with model-driven apps for the back office, and a React Native app for the one field workflow that genuinely needed to be custom.
Conclusion
Use Expo, configure TypeScript strictly and validate at the API boundary, separate server state from client state, put tokens in secure storage, and decide your offline conflict policy before building screens.
Those five decisions account for most of the difference between a React Native app that stays pleasant to work on and one that becomes a maintenance burden by its second year.
About Youssef Al-Jaber
React Native engineer building production mobile apps for logistics and field-service teams, with a focus on offline reliability.