preact
Preact is a virtual DOM library with React's component model in about 4.7 kB gzipped and zero dependencies. You write the same function components, JSX and hooks you already know, but the runtime is small enough to ship inside a widget or a landing page. Two things make it more than a clone. First, it talks to the DOM directly: events are real addEventListener handlers rather than a synthetic system, and props map onto attributes, so class works alongside className. Second, preact/compat is an alias layer that maps the react and react-dom import specifiers onto Preact, which lets a large share of the React library ecosystem run unchanged. Hooks, compat, debug, devtools and the JSX runtime all live in separate entry points, so you only pay for what you import.
The best option when you want React-shaped components in a few kilobytes, and a genuinely good fit for widgets, islands and size-budgeted pages. For a large product app already deep in the React ecosystem, the compatibility edge cases usually cost more than the bytes you save.
Use it if
- Payload size is a hard requirement: embeddable widgets, third-party scripts on other people's pages, marketing pages measured on mobile networks, kiosk or set-top browsers
- You want components, hooks and JSX without adopting the full React runtime, and you are happy to write the app against Preact directly rather than through a compatibility layer
- You are already in a Preact-first stack, for example Deno Fresh, an Astro site using the Preact integration, or an islands architecture where every island carries its own runtime cost
- You have an existing React app whose dependency list is modest and want to try aliasing react to preact/compat, measure the bundle, and keep the change if the test suite stays green
- You depend on the parts of React that compat does not reproduce: React Server Components, the Next.js App Router, React Native, or libraries that reach into react-dom internals. compat covers the public API, not the private one
- You need React semantics exactly. Native events instead of synthetic ones, different update batching and scheduling, and onChange firing on the real change event in core (compat remaps it) all show up as subtle failures when porting a mature test suite
- React is not what makes your bundle big. If application code, a design system and a charting library dominate, saving a few kilobytes of runtime buys you an aliasing config in every bundler, tsconfig path overrides for @types/react, and a new class of debugging sessions
- Your team leans on the React ecosystem for answers. There are fewer Stack Overflow threads, fewer libraries typed against Preact, and error messages from React tutorials do not always map onto what you see
- You want a settled major. v11 is in beta on the default branch and v10 patches come from the v10.x branch, so plan for a migration cycle rather than assuming today's code carries over untouched
Setup reality
npm install preact and you have it: no dependencies, no native build, ESM and CJS both published. The configuration is where the time goes. Hooks are not in the core entry point, they are in preact/hooks. JSX needs either the automatic runtime with jsxImportSource set to "preact" or the classic pragma with h and Fragment, and you have to set that in tsconfig, in your bundler, or both. Running React libraries means aliasing react, react-dom, react-dom/test-utils and react/jsx-runtime to preact/compat in whichever bundler you use, plus a paths entry so TypeScript resolves @types/react to preact/compat types; @preact/preset-vite does this for Vite so you do not hand-roll it. Server rendering needs preact-render-to-string, which is declared as a peer dependency and is not installed for you. Add import "preact/debug" as the very first import in development for prop and hook warnings, and make sure your production build drops it.
Patterns
Mount a component into the DOMrender-an-app
import { render } from 'preact';
function App() {
return <h1>Hello</h1>;
}
render(<App />, document.getElementById('app'));render() diffs against whatever is already in the container rather than replacing it, and there is no createRoot ceremony. Call render(null, container) to unmount.
Configure JSX for Preactjsx-configuration
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
}
}
}jsxImportSource is what points the automatic runtime at preact/jsx-runtime. The paths entries are only needed if you pull in React-typed libraries; without them TypeScript loads @types/react and every element type conflicts.
Use hooks from preact/hookshooks-state-and-effects
import { useState, useEffect } from 'preact/hooks';
function Clock() {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
return <time>{new Date(now).toLocaleTimeString()}</time>;
}Hooks live in preact/hooks, not the main entry, so importing useState from "preact" fails. The rules and dependency arrays behave the same as React.
Handle text input eventscontrolled-inputs
import { useState } from 'preact/hooks';
function NameField() {
const [name, setName] = useState('');
return (
<input
value={name}
onInput={(e) => setName(e.currentTarget.value)}
/>
);
}This is the biggest day-one difference from React: in Preact core, onChange is the native change event that fires on blur, and per-keystroke updates come from onInput. Under preact/compat, onChange is remapped to behave like React.
Point React imports at Preact in Vitecompat-aliasing
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({
plugins: [preact()],
// preset-vite sets these; do it by hand only without the preset:
// resolve: { alias: {
// react: "preact/compat",
// "react-dom": "preact/compat",
// "react/jsx-runtime": "preact/jsx-runtime",
// } },
});Alias react-dom/test-utils as well if your tests use it. After aliasing, run the app before trusting it: libraries that import from react-dom internals or rely on concurrent rendering are where compat gives up.
Share state without prop drillingcontext
import { createContext } from 'preact';
import { useContext } from 'preact/hooks';
const Theme = createContext('light');
function Page() {
return (
<Theme.Provider value="dark">
<Button />
</Theme.Provider>
);
}
function Button() {
const theme = useContext(Theme);
return <button class={theme}>Click</button>;
}createContext comes from preact core, useContext from preact/hooks. Note class rather than className: Preact accepts both, and class is what ends up in the DOM either way.
Use signals instead of hook statesignals-state
// npm install @preact/signals
import { signal, computed } from '@preact/signals';
const count = signal(0);
const double = computed(() => count.value * 2);
function Counter() {
return (
<button onClick={() => count.value++}>
{count} / {double}
</button>
);
}Rendering a signal directly (not signal.value) lets Preact update just that text node instead of re-rendering the component. Reading .value inside the component body subscribes the whole component instead, which is usually not what you wanted.
Lazy load a componentcode-splitting
import { lazy, Suspense } from 'preact/compat';
const Editor = lazy(() => import('./Editor.jsx'));
function Page() {
return (
<Suspense fallback={<p>Loading…</p>}>
<Editor />
</Suspense>
);
}lazy and Suspense come from preact/compat, not core, and they cover code splitting rather than React-style data fetching with concurrent rendering. Suspense-based data libraries built for React 18 are the usual place this breaks.
Render on the server, hydrate on the clientserver-render-and-hydrate
// server: npm install preact-render-to-string
import render from 'preact-render-to-string';
import { App } from './App.js';
const html = render(<App url={req.url} />);
// client
import { hydrate } from 'preact';
hydrate(<App />, document.getElementById('app'));preact-render-to-string is a peer dependency you install yourself. hydrate skips creating DOM nodes and only attaches listeners, so a markup mismatch shows up as missing interactivity rather than a loud error.
Reach a DOM node directlyrefs-and-dom-access
import { useRef, useEffect } from 'preact/hooks';
function Autofocus() {
const input = useRef(null);
useEffect(() => input.current?.focus(), []);
return <input ref={input} />;
}
// callback refs work too
<input ref={(el) => el && el.focus()} />;useRef comes from preact/hooks and behaves like React. forwardRef exists in preact/compat, but in plain Preact a function component can simply accept a ref prop.
Turn on development warnings and devtoolsdev-warnings
// index.jsx, before anything else
if (process.env.NODE_ENV !== 'production') {
await import('preact/debug');
}
// preact/debug also enables the React DevTools bridge;
// import 'preact/devtools' alone for the bridge without the checksIt must be the first import, otherwise components created before it loads are not instrumented. preact/debug adds real bytes and extra checks, so keep it out of the production bundle.
Render into a different DOM subtreeportals
import { createPortal } from 'preact/compat';
function Modal({ children }) {
return createPortal(
<div class="modal">{children}</div>,
document.body,
);
}createPortal lives in preact/compat, so a core-only app has to import that entry point to get it. Events still bubble through the Preact tree, not the DOM tree, which is what you want for modals inside forms.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react | npm | You need the full ecosystem, Server Components or React Native, and the extra bytes are not what your users are complaining about. |
| solid-js | npm | You want JSX with fine-grained reactivity and no virtual DOM at all, and you are willing to unlearn the hooks mental model. |
| lit | npm | You are building framework-agnostic web components for other teams to embed, and standards-based custom elements matter more than JSX. |