Question

How can I suppress React imports in TypeScript output to allow CDN references?

I am trying to use CDN URLs to reference the react and react-dom libraries in my typescript app. I do not want to bundle them into my compiled .js file, as I'm concerned with performance in production. But there seems no straightforward way to achieve this without manually editing the compiled .js file to remove the import references. See this minimal repro (https://jsfiddle.net/grm6ze1b/):

test.html:

<!DOCTYPE html>
<div id="app">
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.min.js"></script>
<script src="test.js" type="module"></script>

test.ts:

import * as React from 'react';
import * as ReactDOM from 'react-dom/client';

class HelloComponent extends React.Component {
    render() {
        return React.createElement("div", null, "Hello, World!");
    }
}

document.addEventListener("DOMContentLoaded", () => {
    const oReactRoot = ReactDOM.createRoot(document.getElementById("app")!);
    oReactRoot.render(React.createElement(HelloComponent, null));
});

tsconfig.json:

{
    "compilerOptions": {
        "target": "es2020",
        "moduleResolution": "node",
        "strict": true,
        "noEmitOnError": true
    },
    "include": [
        "**/*.ts"
    ]
}

First, I got "Uncaught SyntaxError: import declarations may only appear at top level of a module" when loading the page in a browser. OK, so I fixed that by adding type="module" to my script tag for test.js.

Then, I got 'Uncaught TypeError: The specifier “react” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”.'. This is where I'm stuck. I can resolve the issue by manually commenting out the top two import lines in my compiled test.js file. But this seems absurd, since I'll have to do it every time I run tsc to recompile my .ts file.

I've pored over the https://www.typescriptlang.org/tsconfig/ docs, and there are various options controlling the handling of imports and whether they appear in the compiled JS.At https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax it says:

By default, TypeScript does something called import elision. Basically, if you write something like

import { Car } from "./car"; export function drive(car: Car) { // ... }

TypeScript detects that you’re only using an import for types and drops the import entirely.

But this is not happening for me. My compiled test.js still contains the imports, even though I'm only importing the React types and not the full library:

PS C:\inetpub\wwwroot\TypeScriptReactTest> npm ls --all
TypeScriptReactTest@ C:\inetpub\wwwroot\TypeScriptReactTest
+-- @types/react-dom@18.2.25
| `-- @types/react@18.2.79 deduped
`-- @types/react@18.2.79
  +-- @types/prop-types@15.7.12
  `-- csstype@3.1.3

A third solution I tried is to change my imports to reference directives:

///<reference types="react"/>
///<reference types="react-dom/client"/>

But this yielded "error TS2339: Property 'createRoot' does not exist on type 'typeof import("C:/inetpub/wwwroot/TypeScriptReactTest/node_modules/@types/react-dom/index")'." (Same error if I tried reference paths="" instead.)

Lastly, I tried defining an import map to tell the browser where to find the modules I am importing:

<script type="importmap">
 {
    "imports": {
      "react": "https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js",
      "react-dom/client": "https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.min.js"
    }
  }
</script>

That yielded a new error: "Uncaught TypeError: class heritage React.Component is not an object or null".

So I am banging my head against a brick wall here, and would be grateful for any help. I really hope the answer is not that I have to use something like https://webpack.js.org/configuration/externals/ to handle this. My full production app (not this repro) uses ASP.NET Core WebOptimizer bundling, and I'd really hate to add another step to my toolchain just to handle what I assume is pretty basic and common use case.

 3  108  3
1 Jan 1970

Solution

 2

React was not designed to be used by standard JavaScript modules (ESM), and has not yet been refactored to natively support them (ref: GitHub tracking issue) at the time I write this answer. Until the time that it natively supports ESM, you must either

  1. rely on some tool to transform the CommonJS code published by React — such as a bundler or a third-party CDN that transforms to ESM, or

  2. write your code to use the globals provided by the UMD modules you've shown in the question.

From the details in your question, it sounds like you want to avoid additional local tooling such as a bundler… and while relying on (the output of) an ESM CDN could be an option, it might be preferable to simply refactor your import statements and import map. Below I'll describe how to do that.

The problem

Each UMD module you've linked to in the question contributes a global variable. The react UMD module creates one named React and the react-dom UMD module creates one named ReactDOM. However, the corresponding TypeScript declarations for these modules (@types/react, @types/react-dom) don't use those global variables.

Import maps

You can define your import map entries to map from the bare specifiers to modules that export the corresponding global variable as the default export. This pattern is supported by the type declarations for react and react-dom. For example:

react_umd.js:

export default globalThis.React;

react_dom_umd.js:

export default globalThis.ReactDOM;

import map:

{
  "imports": {
    "react": "./react_umd.js",
    "react-dom/client": "./react_dom_umd.js"
  }
}

Import statements

Then, instead of using a namespace import statement for each package…

import * as React from "react";
import * as ReactDOM from "react-dom/client";

…import each namespace from the default export:

import { default as React } from "react";
import { default as ReactDOM } from "react-dom/client";

Using this pattern is also supported when bundling, so if you decide to bundle your code in the future, you won't need to refactor this syntax.

This might also require you to update your TSConfig to use the compiler option esModuleInterop, which is implied when setting module to NodeNext:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2020",
    "module": "NodeNext",
    "noEmitOnError": true
  },
  "include": ["**/*.ts"]
}

Be sure to recompile with tsc!

HTML

Use the updated import map in your HTML. Be sure to order it before your module script:

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.js" integrity="sha512-YFI6ChaPQ5hH9o8Q4n5ZzDHrhrwZ3dhgZSQ2JC/pgmYuD0QtG0iwQgfFa1J+o4jvklsKBupcHz5Tx1yqa25FFQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js" integrity="sha512-aTIGujEp0xIZSXrXXFjXL3YiozmMRYjKmll3rLYTmUIGaaidsrQNn+ii04E+VwlpIUNZOF3UBoXRmNQBEdD/qQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

<script type="importmap">
  {
    "imports": {
      "react": "./react_umd.js",
      "react-dom/client": "./react_dom_umd.js"
    }
  }
</script>

<script type="module" src="test.js"></script>

<div id="app"></div>

Reproduction in a code snippet

For the purpose of a single-file reproducible example that runs in this answer, I'll convert the two modules above (react_umd.js and react_dom_umd.js) to Data URLs and I'll inline the content of test.js (after compiling test.ts):

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.js" integrity="sha512-YFI6ChaPQ5hH9o8Q4n5ZzDHrhrwZ3dhgZSQ2JC/pgmYuD0QtG0iwQgfFa1J+o4jvklsKBupcHz5Tx1yqa25FFQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js" integrity="sha512-aTIGujEp0xIZSXrXXFjXL3YiozmMRYjKmll3rLYTmUIGaaidsrQNn+ii04E+VwlpIUNZOF3UBoXRmNQBEdD/qQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

<script type="importmap">
  {
    "imports": {
      "react": "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQgZ2xvYmFsVGhpcy5SZWFjdDs=",
      "react-dom/client": "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQgZ2xvYmFsVGhpcy5SZWFjdERPTTs="
    }
  }
</script>

<!-- This is the inlined content of "test.js" after compiling with tsc -->
<script type="module">
import { default as React } from "react";
import { default as ReactDOM } from "react-dom/client";
class HelloComponent extends React.Component {
    render() {
        return React.createElement("div", null, "Hello, World!");
    }
}
document.addEventListener("DOMContentLoaded", () => {
    const oReactRoot = ReactDOM.createRoot(document.getElementById("app"));
    oReactRoot.render(React.createElement(HelloComponent, null));
});
</script>

<div id="app"></div>

2024-07-06
jsejcksn