12k
All articles

Running React Libraries in Preact Using preact/compat

Use preact/compat to run React libraries in Preact, with alias configs for Vite, webpack, Rollup, Jest, TypeScript, and common breakages.

OpenReplay Team
OpenReplay Team
Running React Libraries in Preact Using preact/compat

preact/compat is a compatibility layer, shipped inside the main preact package since Preact X, that maps React’s public API onto Preact so most React libraries run unmodified while your app ships around 9.5KB of framework instead of React’s much larger runtime.

Swapping the alias in is a five-minute job. Finding out three days later that one date picker is throwing errors from somewhere deep inside node_modules is the part nobody warns you about. You enable it by aliasing react and react-dom to preact/compat in your bundler: no code changes to your components, no separate package to install. This guide gives you the exact alias config for every major toolchain, a migration walkthrough with the bundle-size payoff, and an honest account of which libraries break.

Key Takeaways

  • preact/compat ships inside the preact package. Compat now lives in core, so the standalone preact-compat package is obsolete and npm install preact is all you need.
  • The entire mechanism is aliasing four import paths: react, react-dom, react-dom/test-utils, and react/jsx-runtime all point at Preact.
  • With @preact/preset-vite, aliasing is automatic, so you do not hand-write resolve.alias.
  • In webpack the react-dom alias must be listed below react-dom/test-utils, or the broader rule shadows the test-utils mapping.
  • Compat covers React’s public API, not its internals. Libraries reaching into deep react-dom internal paths, or depending on the newest React 19 APIs, can still break.

What is preact/compat, and why does it exist?

preact/compat translates React’s public API surface (React.Component, hooks, createPortal, forwardRef, memo, the JSX runtime) into Preact equivalents so third-party components written against React resolve to Preact at build time. Preact’s own npm listing sells the library on wide React support behind a single alias, and that compatibility is what lets you reuse the React ecosystem without a rewrite.

There is no preact-compat package to install anymore. The official upgrade guide explains that the layer once shipped on its own and was folded into the core repository to keep coordination simpler, so anyone moving up has to swap old preact-compat imports and aliases for preact/compat. The unscoped package is a dead end: its GitHub repository has been archived and read-only since December 2021, and its npm page tells you to uninstall it, since Preact X carries compat by default. Preact 10.x is the current stable line, with 11.0.0 at release-candidate stage rather than general availability; the Preact releases page lists the exact version numbers.

Aliasing Is the Whole Trick

The entire mechanism is aliasing: you point react, react-dom, react-dom/test-utils, and react/jsx-runtime at Preact so every existing import, including third-party libraries deep in node_modules, resolves to preact/compat instead of React. Nothing in your component code changes. An import { useState } from 'react' statement stays exactly as written; the bundler rewrites where react resolves.

The four canonical entries, from Preact’s guide to aliasing React to Preact:

Import pathAlias targetWhy
reactpreact/compatCore React API
react-dom/test-utilspreact/test-utilsTest utilities
react-dompreact/compatDOM renderer (must sit below test-utils)
react/jsx-runtimepreact/jsx-runtimeAutomatic JSX transform

Aliasing only react and react-dom, a common shortcut in older tutorials, leaves libraries that import the JSX runtime or react-dom/test-utils resolving to React, which reintroduces the bytes you were trying to drop.

Per-Toolchain Alias Config

With @preact/preset-vite, aliasing is automatic and you should not hand-write resolve.alias. The preset switches the React aliases on for you: the reactAliasesEnabled option governs them and is set to true unless you turn it off. It also configures the JSX transform for you.

// vite.config.ts
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';

export default defineConfig({
  plugins: [preact()], // JSX + react→preact/compat aliasing handled automatically
});

If you run Vite without the preset, add the manual fallback:

export default defineConfig({
  resolve: {
    alias: {
      react: 'preact/compat',
      'react-dom/test-utils': 'preact/test-utils',
      'react-dom': 'preact/compat',
      'react/jsx-runtime': 'preact/jsx-runtime',
    },
  },
});

Webpack

In webpack the react-dom alias must be listed below react-dom/test-utils, otherwise the broader react-dom rule shadows the test-utils mapping and the test utilities silently resolve to the wrong module.

const config = {
  resolve: {
    alias: {
      react: 'preact/compat',
      'react-dom/test-utils': 'preact/test-utils',
      'react-dom': 'preact/compat', // Must be below test-utils
      'react/jsx-runtime': 'preact/jsx-runtime',
    },
  },
};

Rollup

Install @rollup/plugin-alias and register it before @rollup/plugin-node-resolve, so the rewrites happen before Rollup resolves modules.

import alias from '@rollup/plugin-alias';

export default {
  plugins: [
    alias({
      entries: [
        { find: 'react', replacement: 'preact/compat' },
        { find: 'react-dom/test-utils', replacement: 'preact/test-utils' },
        { find: 'react-dom', replacement: 'preact/compat' },
        { find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' },
      ],
    }),
  ],
};

Node / Next.js (no bundler alias)

Node runtimes ignore bundler aliases, Next.js included, so the alias goes in package.json instead, using the published @preact/compat package. That scoped package exists only so npm’s built-in aliasing has something to point at; its whole job is to re-export preact/compat unchanged. Note the scoped @preact/compat is not the dead unscoped preact-compat.

{
  "dependencies": {
    "react": "npm:@preact/compat",
    "react-dom": "npm:@preact/compat"
  }
}

Jest

Jest rewrites module paths with regex entries under moduleNameMapper:

{
  "moduleNameMapper": {
    "^react$": "preact/compat",
    "^react-dom/test-utils$": "preact/test-utils",
    "^react-dom$": "preact/compat",
    "^react/jsx-runtime$": "preact/jsx-runtime"
  }
}

TypeScript

TypeScript resolves types independently of your bundler, so map the paths in tsconfig.json and enable skipLibCheck. Turn skipLibCheck on because a handful of React libraries lean on types that compat does not ship, and a full pass over every .d.ts in node_modules will fail on those declarations.

{
  "compilerOptions": {
    "skipLibCheck": true,
    "baseUrl": "./",
    "paths": {
      "react": ["./node_modules/preact/compat/"],
      "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
      "react-dom": ["./node_modules/preact/compat/"],
      "react-dom/*": ["./node_modules/preact/compat/*"]
    }
  }
}

A Minimal Migration Walkthrough

Migrating an existing React app to Preact on modern tooling is a four-step operation:

  1. Swap dependencies. Remove react, react-dom, and their @types, since Preact ships its own TypeScript types, then npm install preact and npm install -D @preact/preset-vite.
  2. Add the preset. Put preact() in your Vite plugins. It handles both the JSX transform and the react → preact/compat alias, so you delete any manual esbuild.jsxInject / jsxFactory config from older Vite 2-era setups.
  3. Change the render entry point. Swap React DOM’s mount call for Preact’s render:
// Before
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

// After
import { render } from 'preact';
render(<App />, document.getElementById('root'));
  1. Build and check size. The payoff is the point: Preact core plus preact/compat lands at roughly 9.5KB min+gzip, against something closer to 60KB for React 19 plus React DOM, nearly all of it in the react-dom/client entry. For an app whose components already resolve through compat, that difference is close to free.

When does preact/compat break?

Compat covers React’s public API, not its internals. Libraries that reach into private react-dom internal paths, or that lean on some of the newer React 19 APIs, can break even when the alias is correct, so verify each dependency before shipping. Type mismatches from React-typed libraries are expected and handled by skipLibCheck; runtime failures are the ones to watch, and session replays of these integrations frequently surface them as console errors thrown from inside a dependency rather than your own code.

A quick pre-flight check before committing a library:

  • Grep the package for deep react-dom/ internal imports, the most common breakage signal.
  • Check for React 19-only APIs the library depends on; verify coverage against the current Preact release rather than assuming.
  • Run the library’s own test suite under the Jest moduleNameMapper above to catch failures early.
  • Smoke-test in dev, watching the console for errors originating inside the dependency.

SSR and framework users hit a separate class of issue: because bundler aliases don’t apply in Node, Next.js and similar runtimes need the package.json alias, and Vite’s ssrLoadModule path can bypass some alias config, so confirm both client and server resolve to preact/compat.

Alias the four entries for your toolchain, run your dependencies’ tests through the same mapping, and you can reuse most of the React ecosystem at a fraction of the bytes. The honest exceptions are libraries coupled to React’s internals rather than its public API. Start by adding @preact/preset-vite to a branch and measuring your production bundle before and after.

FAQs

What is the difference between @preact/compat and the old preact-compat package?

The scoped @preact/compat is a live npm package that re-exports preact/compat, used only to alias react through package.json in Node runtimes like Next.js where bundler aliases do not apply. The unscoped preact-compat is a different, archived package whose repository has been read-only since December 2021; it targeted Preact 8.x, and Preact X now ships compat inside core. Never install the unscoped one.

Do I still need to write resolve.alias manually if I use @preact/preset-vite?

No. With @preact/preset-vite, aliasing react and react-dom to preact/compat happens for you, governed by the reactAliasesEnabled option, which is on unless you turn it off. Adding preact() to your Vite plugins handles both the JSX transform and the aliasing, so hand-writing resolve.alias is redundant and can conflict. You only write the manual four-entry alias block when running Vite without the preset.

Why do my test utilities resolve to the wrong module after aliasing in webpack?

Because the react-dom alias is listed above react-dom/test-utils in your webpack config. Webpack matches the broader react-dom rule first, so it shadows the more specific test-utils mapping and the test utilities silently resolve to preact/compat instead of preact/test-utils. Fix it by placing the react-dom entry below react-dom/test-utils. Rollup has a related ordering rule: put @rollup/plugin-alias before @rollup/plugin-node-resolve.

Why does a React library break even though my preact/compat alias is correct?

Because compat maps React's public API, not its internals. Libraries that import deep react-dom internal paths, or depend on the newest React 19 APIs, can fail at runtime even with a correct alias. These surface as console errors thrown from inside the dependency, not from your own code. Before committing a library, grep it for deep react-dom/ imports, and run its own test suite under the Jest moduleNameMapper.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.