In the evolving landscape of React Native and Expo development, the transition from CommonJS (CJS) to ECMAScript Modules (ESM) is no longer just a trend—it is a necessity for modernizing the development lifecycle. This post outlines the pragmatic reasons behind migrating an Expo TypeScript project to ESM, the performance gains achieved, and the real-world friction points encountered along the way.
Aside: You may leverage AI Coding tool, but the tool doesn't pick the right tech-stack all the time. So it is indeed important you know what you want and the trade-offs for the choice of your tech-stack. I had to iterate over this, albeit done quickly with my choice of AI Coding Agent - Claude Code for this.
The Core Shift: Static vs. Dynamic
The fundamental difference lies in how modules are loaded. CommonJS is dynamic; modules are evaluated at runtime. ESM is static; the structure is determined at compile time.
The Advantages of ESM in React Native
1. Superior Tree-Shaking
Because ESM imports are static, bundlers like Metro or Webpack can reliably determine which parts of a library are actually used. In a CJS environment, require() is often opaque to the bundler, leading to "bloated" bundles where entire libraries are included even if you only need one utility function.
Before (CJS):
```
// Even if we only use 'sum', the whole library might be bundled
const { sum } = require('./mathUtils');
```
After (ESM):
```
// Bundler can safely discard everything except 'sum'
import { sum } from './mathUtils';
```
2. Standardization and Future-Proofing
ESM is the official standard for JavaScript. By adopting it, your Expo project aligns with the browser environment and modern Node.js features. This reduces the cognitive load when switching between frontend web projects and mobile React Native code.
3. Top-Level Await
One of the most powerful features of ESM is the ability to use await outside of an async function. This is particularly useful in Expo for initializing configuration services or native modules before the app fully boots.
```
// config.ts (ESM)
const data = await fetchConfig();
export const config = data; // Guaranteed to be populated when imported
```
This is so easy compared to the CommonJS standards, where the root level of the file was strictly synchronous. If you needed to perform an asynchronous task (like fetching a config or connecting to a database) before your module was "ready," you couldn't just pause the file. You had two main workarounds, both of which were a bit of a headache:
- The "IIFE" Wrapper (Immediately Invoked Function Expression): You had to wrap your logic in an async function. The problem? The rest of your app would keep running while this function was still pending.
```
// config.js (CJS)
let config = {};
(async () => {
const data = await fetchConfig();
config = data;
})();
// This might export an empty object because fetchConfig hasn't finished!
module.exports = config;
``` - The Exported Promise: To make sure other files didn't use your module before it was ready, you had to export the Promise itself. This meant every file importing your module had to await it manually.
```
// config.js (CJS)
const initialize = async () => {
return await fetchConfig();
}
module.exports = initialize();
// main.js
const configPromise = require('./config');
async function start() {
const config = await configPromise; // You're forced to await this everywhere.
console.log(config);
}
```
4. Better Type Inference
In a TypeScript-heavy Expo project, ESM provides a cleaner mapping for type definitions. The import type syntax is explicit, ensuring that types are stripped away during the build process without causing side-effect issues.
```
import type { UserProfile } from './types';
import { fetchUser } from './api';
```
Pragmatic Migration Steps
To move toward ESM in an Expo project, we typically update the `package.json` as below:
```package.json
{
"type": "module"
}
```
and `tsconfig.json` (a file that TypeScript compiler - `tsc` looks for by default to understand how to build your project) as below:
```tsconfig.json
```tsconfig.json
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
}
}
```
The Reality Check: The Cons and "Gotchas"
While the benefits are significant, the "real world" of React Native development is still catching up. Here are a couple of nagging challenges I faced in modernizing legacy android app.
1. Library Compatibilities
While many libraries are moving towards ESM standards that are still some left behind. Detox is one such library that is used for E2E testing of mobile apps over android emulators/devices. I couldn't get it working in my set-up making me look for alternatives. And eureka!, I found Maestro that is way easier to setup and code all in YAML format.
2. Asset Handling and Dynamic Imports
In React Native, local assets (images, fonts) are traditionally handled via require(). While ESM supports import for static assets, dynamic selection of assets still feels more natural in CJS.
```
```
//The ESM limitation with assets:
// ESM doesn't support dynamic string interpolation in static imports
// import icon from `./assets/${status}.png`; // Syntax Error
// We are forced to keep using require() for local assets
const icon = require(`./assets/${status}.png`);
```
So to conclude, migrating to ESM in an Expo project is a strategic move for performance and long-term maintainability. The benefits of tree-shaking and modern syntax outweigh the initial setup friction. There sure are some libraries that has yet adopted ESM standards and I think it is only a matter of time that they catch-up as ESM is the contemporary standard. Until then, either figure out an alternative like I did or fight your own battle managing to get it work with your setup.
Update: Ground-Zero Lessons From This Approach
I am a big believer in standardization of tools ecosystem and I typically take a byte-sized approach to modernization. The advantage of these founding principle is that the cost of failure is small and I get to learn my lessons faster.
Now, in my attempts with the above approach, it worked well for a small mobile app with simpler features but became a nightmare of cyclical-errors where when I fix one, the other broke as a pattern until I reached back to where I started :/
Aside: I have been leveraging Claude Code for this and found that it was doing this in repeated cycle consuming a ton of token in this wasted efforts. My assumption that Claude Code leverages Ralf-loop by default for tasks assigned to it was wrong. When I realized this, I went through this cycle myself manually to learn this lesson the hard-way. Phew!, who said "Software Engineering is dead"?
I for the specifics, my troubleshooting focussed on a single architectural conflict: forcing a strict Node ESM runtime ( `"type": "module"` setting in your `package.json` ) onto an ecosystem (Expo, RN, Jest) that fundamentally relies on CommonJS (CJS) translation layers.
- The Asset Resolver Clash: In a pure ESM environment, standard CommonJS style image references—like `require('../../image.jpg')` — break. Jest's custom React Native file resolver expects either direct string maps or absolute file URLs. We resolved this by implementing a Custom Binary Transformer (`jest-binary-transformer.cjs`), which catches image assets before they reach the file system and instantly transforms them into safe JavaScript objects.
- The Native Bridge Bottleneck: Moving to Expo 54 and React 19 introduces modern layout subsystems. Under raw Node ESM execution, calling native layout layout helpers like `useWindowDimensions()` completely bypasses standard initialization scripts. This triggered fatal errors like "PlatformConstants could not be found" or `__fbBatchedBridgeConfig is not set`.
- The Breakthrough: By returning to the "Write ESM, Test as CommonJS" middle ground, we let Babel handle the underlying module resolution safely during test compilation. This completely eliminated the fragile global overrides, filesystem loops, and complex string rewriters.
Do check out my follow-up post - Write ESM, Test CJS: The Pragmatic Blueprint for Expo 54 and React 19 using Jest for testing - for more on how I went about solving this puzzle.
