Buy @ Amazon

Why I Migrated to ECMAScript Module (ESM) Standards Over CommonJS (CJS)?

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:
  1. 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;
    ```
  2. 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
{
  "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.