Preact as a 3KB React Alternative: Hooks, Signals, preact/compat and Where It Differs
Key takeaways
Preact is a fast 3KB alternative to React with the same modern API. It's perfect for performance-critical applications and works with most React libraries.
Introduction
Preact is a fast 3KB alternative to React with the same modern API. It’s not a reimplementation of React, but a fresh take on the Virtual DOM with a focus on performance and size.
Created by Jason Miller (Google Chrome team member, creator of wmr and many performance tools), Preact represents what React would look like if it prioritized size and speed above all else.
Why Preact Matters
The performance impact of bundle size:
- Preact: about 3–4KB gzipped for the core
- React + ReactDOM: 40KB+ gzipped (the
reactpackage itself is small; most of the weight isreact-dom)
Download size is only part of the story. On a mid-range phone, every kilobyte of JavaScript also has to be parsed, compiled, and executed before the page becomes interactive, and that cost is often larger than the network time. A smaller framework shortens that critical path on exactly the devices where it hurts most. The flip side is that the framework is rarely the biggest thing in a real bundle: an app with 400KB of its own code and dependencies saves proportionally little by switching. Measure your bundle (for example with vite build plus a visualizer) before deciding the framework is the problem.
Preact is widely used in production for performance-sensitive pages and embeddable widgets, and several large sites have written publicly about moving mobile web experiences to it; the pattern in those write-ups is usually “React’s API, smaller runtime”, not a rewrite.
Production use cases where Preact excels:
- Embedded widgets — third-party widgets that load on other sites (ads, chat, analytics)
- E-commerce product pages — where load time directly affects conversion
- Progressive Web Apps — offline-first apps where bundle size matters
- Mobile-first sites — emerging markets with slow networks
When to choose Preact:
- Performance is critical (e-commerce, mobile-first)
- Building embeddable widgets for third-party sites
- Bundle size matters (need <5KB base framework)
- Already using React patterns, want instant performance win
When to use React instead:
- Need React Native for mobile apps
- Using many React-specific libraries (check compatibility)
- Team is React-expert and performance is “good enough”
- Corporate requirement (React is backed by Meta)
- Relying on React Server Components or the newest concurrent features, which Preact does not implement
React vs Preact
React (40KB):
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Preact (3KB):
import { h } from 'preact';
import { useState } from 'preact/hooks';
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Almost identical! The visible difference is where hooks come from: in Preact they live in a separate preact/hooks entry point, so an app that uses only class components or signals doesn’t pay for them. The invisible differences are in the renderer. Preact diffs the virtual DOM directly against the real DOM, attaches native event listeners instead of React’s synthetic event system, and has no concurrent scheduler — updates are batched and rendered, but not time-sliced or interruptible. For most UIs you will not notice; for very large lists or heavy re-renders, React’s scheduling can keep input responsive where Preact would block briefly.
Installation
New Project with Vite
npm create vite@latest my-app -- --template preact-ts
cd my-app
npm install
npm run dev
Add to Existing Project
npm install preact
Core Concepts
JSX and h()
import { h } from 'preact';
// JSX (recommended)
const element = <div>Hello</div>;
// h() function (JSX compiles to this)
const element = h('div', null, 'Hello');
Whether you need import { h } from 'preact' depends on how JSX is compiled. With the classic transform (jsxFactory: "h"), every file that contains JSX must import h, and forgetting it produces ReferenceError: h is not defined at runtime. The Vite template and @preact/preset-vite use the automatic runtime ("jsx": "react-jsx" with "jsxImportSource": "preact" in tsconfig.json), which imports the JSX helpers for you, so the h imports in the examples below are harmless but unnecessary. If you see React’s JSX types in editor errors, jsxImportSource is usually the missing setting.
Components
import { h } from 'preact';
// Function component
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// With TypeScript
interface GreetingProps {
name: string;
}
function Greeting({ name }: GreetingProps) {
return <h1>Hello, {name}!</h1>;
}
Props and Children
function Card({ title, children }) {
return (
<div class="card">
<h2>{title}</h2>
<div>{children}</div>
</div>
);
}
// Usage
<Card title="My Card">
<p>Card content</p>
</Card>
Hooks
useState
import { useState } from 'preact/hooks';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(c => c + 1)}>Increment (updater)</button>
</div>
);
}
useEffect
import { useEffect } from 'preact/hooks';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]); // Re-run when userId changes
return <div>{user?.name}</div>;
}
Hooks behave as in React, including the rules: call them unconditionally at the top level of a component, and list every value the effect reads in the dependency array. This example has the same race condition React code does — if userId changes quickly, an older response can arrive last and overwrite the newer one. Return a cleanup function that sets an ignore flag or aborts the request with an AbortController. One timing difference to know: Preact runs useEffect callbacks after the browser paints, via requestAnimationFrame batching, so code that measures layout should use useLayoutEffect, exactly as in React.
useMemo
import { useMemo } from 'preact/hooks';
function ExpensiveList({ items, filter }) {
const filtered = useMemo(() => {
console.log('Filtering...');
return items.filter(item => item.includes(filter));
}, [items, filter]);
return (
<ul>
{filtered.map(item => <li key={item}>{item}</li>)}
</ul>
);
}
useCallback
import { useCallback } from 'preact/hooks';
function TodoList({ todos }) {
const [filter, setFilter] = useState('');
const handleFilter = useCallback((e) => {
setFilter(e.target.value);
}, []);
return (
<div>
<input onInput={handleFilter} />
{/* A memoized child receiving handleFilter won't re-render because of it */}
</div>
);
}
Note onInput rather than onChange: in plain Preact, event props map to native DOM events, and a text input’s native change event fires only on blur (see the FAQ below). useCallback only pays off when the function is passed to a child wrapped in memo or used in another hook’s dependency list; on a plain DOM element like <input>, a new function each render costs essentially nothing.
useRef
import { useRef } from 'preact/hooks';
function TextInput() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} />
<button onClick={focusInput}>Focus</button>
</div>
);
}
React Compatibility
Using preact/compat
npm install preact
vite.config.ts:
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({
plugins: [preact()],
resolve: {
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
},
},
});
Now you can use React libraries!
// Works with Preact via compat
import { useState } from 'react';
import { createPortal } from 'react-dom';
preact/compat ships inside the preact package, so there is nothing extra to install, and @preact/preset-vite already sets up these aliases by default — the explicit resolve.alias block is only needed with other bundlers or custom setups. Aliasing also has to cover react/jsx-runtime and, for tests, your test runner (Jest’s moduleNameMapper, Vitest’s alias). For TypeScript, add paths entries mapping react and react-dom to preact/compat so that third-party type definitions resolve to Preact’s types.
Compatibility is good but not perfect, and the failures cluster in a few places. Libraries that reach into React internals (__SECRET_INTERNALS..., the fiber tree) break. Features built on React 18+ concurrency — useTransition semantics, streaming Suspense on the server, Server Components — are either shimmed with simpler behavior or unavailable. And the most confusing failure is two copies of a framework in one bundle: if one dependency imports react through a path the alias doesn’t catch, you get React and Preact, and hooks fail with errors like Cannot read properties of undefined (reading '__H') because a hook ran without a Preact component rendering it. When I see that error, the first thing to check is the bundle for a stray react-dom copy.
Signals (Preact’s Secret Weapon)
npm install @preact/signals
import { signal, computed } from '@preact/signals';
const count = signal(0);
const doubled = computed(() => count.value * 2);
function Counter() {
// No useState needed! Signals auto-update
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
}
Why Signals?
- Fewer re-renders: updates can go straight to the DOM text node
- Dependencies are tracked automatically, no dependency arrays
- Global state without context
The detail that makes this work is where the signal is read. Passing the signal itself into JSX — {count} — lets Preact bind it to a text node; when count.value changes, only that text node updates and Counter does not re-render at all. Reading .value inside the render body — {count.value} — subscribes the whole component instead, so it re-renders like it would with useState. Both are correct; the first is the optimization people mean when they say signals “skip rendering”. Signals created at module level, as here, are global and shared by every component and every test; for per-component state use useSignal() and useComputed(), which create signals tied to the component’s lifetime. And because a signal is a mutable container, reassign it rather than mutating in place: todos.value.push(x) changes the array but does not notify anyone, while todos.value = [...todos.value, x] does.
Routing
npm install preact-router
import { Router, Route } from 'preact-router';
function App() {
return (
<Router>
<Home path="/" />
<Profile path="/profile/:user" />
<NotFound default />
</Router>
);
}
function Profile({ user }) {
return <h1>Profile: {user}</h1>;
}
preact-router matches the path prop on each child and passes URL parameters as props, which is why Profile receives user directly (the imported Route component is only needed when you want to pass the component as a prop instead). It is small and fine for simple apps, but the Preact team’s newer preact-iso package provides a router with lazy loading and SSR prerendering support designed to work together, and is what current Preact templates tend to use. If you run React code through compat, React Router also works.
Server-Side Rendering
import { render } from 'preact-render-to-string';
const html = render(<App />);
console.log(html); // <div>...</div>
render from preact-render-to-string produces a string synchronously; effects do not run on the server, so data must be fetched before rendering and passed in as props or serialized state. On the client, call hydrate(<App />, document.getElementById('app')) from preact instead of render, so Preact attaches to the existing markup rather than rebuilding it. The server and client must render the same tree from the same data; otherwise hydration patches the DOM to match, which shows up as flicker or lost input focus. For async components and Suspense on the server, newer versions of preact-render-to-string also offer an async renderer.
Performance Tips
Use Signals for Global State
// Global signal (no context needed)
import { signal } from '@preact/signals';
export const user = signal(null);
// Use anywhere
function Header() {
return <div>Welcome, {user.value?.name}</div>;
}
Memoize Components
import { memo } from 'preact/compat';
const ExpensiveComponent = memo(({ data }) => {
// Only re-renders when data changes
return <div>{data}</div>;
});
Lazy Load Components
import { lazy, Suspense } from 'preact/compat';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
Differences from React
class vs className
// Preact: use "class" (HTML standard)
<div class="container">Hello</div>
// React: use "className"
<div className="container">Hello</div>
// Both work in Preact!
Event Naming
// Preact: lowercase events work
<input onchange={handleChange} />
// React: camelCase only
<input onChange={handleChange} />
// Both work in Preact!
No Synthetic Events
Preact uses native browser events (faster!):
function handleClick(e) {
// e is a native Event, not SyntheticEvent
console.log(e.target);
}
Native events have consequences beyond speed. Listeners are attached to each element rather than delegated to the root, so e.stopPropagation() behaves exactly as in the DOM specification. Event names map to DOM events literally, which is the root of the onChange difference in the FAQ below and of events like onDoubleClick (React’s name) needing to be onDblClick in plain Preact — compat translates these for you. Event objects are not pooled, so reading e.target asynchronously works; and with TypeScript, handlers are typed with DOM types (TargetedEvent/JSX.TargetedEvent) rather than React.ChangeEvent.
Real-World Example
Todo App with Signals
import { signal, computed } from '@preact/signals';
const todos = signal([
{ id: 1, text: 'Learn Preact', done: false },
]);
const filter = signal('all');
const filteredTodos = computed(() => {
if (filter.value === 'all') return todos.value;
return todos.value.filter(t =>
filter.value === 'done' ? t.done : !t.done
);
});
function TodoApp() {
const addTodo = (text) => {
todos.value = [...todos.value, {
id: Date.now(),
text,
done: false,
}];
};
const toggleTodo = (id) => {
todos.value = todos.value.map(t =>
t.id === id ? { ...t, done: !t.done } : t
);
};
return (
<div>
<input onKeyUp={(e) => {
if (e.key === 'Enter') {
addTodo(e.target.value);
e.target.value = '';
}
}} />
<div>
<button onClick={() => filter.value = 'all'}>All</button>
<button onClick={() => filter.value = 'active'}>Active</button>
<button onClick={() => filter.value = 'done'}>Done</button>
</div>
<ul>
{filteredTodos.value.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</li>
))}
</ul>
</div>
);
}
This example keeps all state in module-level signals, so TodoApp itself only re-renders when filteredTodos.value changes, and adding a todo never touches the filter buttons. Two small things would need fixing for production: the uncontrolled input resets itself by writing e.target.value = '', which is fine but bypasses any state; and id: Date.now() can collide if two todos are added in the same millisecond, so use crypto.randomUUID() for ids.
Bundle Size Comparison
| Library | Size (gzipped, approximate) |
|---|---|
| Preact | 3–4KB (core) |
| Vue | ~30KB+ (runtime) |
| React + ReactDOM | ~40KB+ |
Sizes vary by version and by which features you import, so treat these as orders of magnitude and check your own build output.
Frequently Asked Questions (FAQ)
Q. Why does onChange on a text input only fire when the field loses focus in Preact?
A. Preact attaches native DOM events, and the native change event on a text input fires when the value is committed (usually on blur), not on every keystroke. React fires onChange on every keystroke because it maps it to the input event internally. In plain Preact use onInput for live updates. If you run React code through preact/compat, it adjusts onChange to behave the React way.