I like tools that stay close to the browser. Preact does that and still feels familiar if you come from React. This doc is about how I set it up, how it keeps code light, where it fits next to React, and a small contribution I made that later led to a fix upstream The goal here is to help you understand Preact and Roll out and not to document a manual.
A quick picture of Preact and React together
Fig:1 React and Preact share the same model, but Preact trims the overhead
A short idea of Preact
Preact is a compact runtime for JSX and components. It uses the native event system that the browser provides. That choice keeps overhead down and makes interop with plain DOM code simple. The core is small, and extra parts live in packages you add only when you need them. For example, the compat layer lets most React libraries run without pulling React itself. See the guide on event semantics and compat notes for details.
Clear view next to React
You can move between the two without changing how you think about components. Here are the points that tend to matter when you are shipping.
-
Events. In Preact,
onInput
fires on every keystroke andonChange
fires when the value is committed. In React, theonChange
prop behaves likeonInput
. When porting code, map this one carefully. - Bundle shape. Preact keeps the base small and places extras behind imports, so you do not pay for features you are not using. The compat alias can be enabled only for the packages that need it.
-
Server render. You can render to HTML and then attach on the client with
preact-render-to-string
. It works in Node and also in the browser if you really need it for tests.
How it stays lightweight
Two design choices keep Preact small and fast.
First, its core doesn’t try to recreate browser behavior. It uses the browser’s native event system instead of adding a synthetic layer, which means less code to ship and fewer surprises when mixing with plain DOM APIs.
Second, anything extra like compatibility layers, debug tools, or dev only helpers lives in separate packages. You bring them in only when you need them.
and similarly If you enable compat
for one React-only library, check its cost in your own build and keep everything else on core Preact. The Vite preset makes toggling this easy.
Build setup that does not grow out of control
// vite.config.ts
// I keep compat off unless a dependency truly needs React types or internals.
// Dev tools stay on only in development. This keeps the production bundle lean.
import preact from '@preact/preset-vite';
export default {
plugins: [preact({
reactAliasesEnabled: false,
devToolsEnabled: true
})]
};
If you want static pages with client features, the preset can prerender routes at build time. For server HTML, use preact-render-to-string and then attach on the client.
// server.ts
// This renders a full HTML string. Keeping this step simple so it's easy to follow and debug later.
import { renderToString } from 'preact-render-to-string';
import { App } from './App';
export function render(url: string) {
const html = renderToString(<App url={url} />);
return `<!doctype html><html><body>
<div id="app">${html}</div>
<script type="module" src="/client.tsx"></script>
</body></html>`;
}
// client.tsx
// We attach interactivity to the server HTML. The root stays stable.
import { hydrate } from 'preact';
import { App } from './App';
hydrate(<App />, document.getElementById('app')!);
Forms that match the platform
In Preact, form events behave just like they do in plain HTML. If you want your code to respond every time the user types, use onInput.
If you only care when the user finishes typing or leaves the field, use onChange. Stick to these browser rules and your forms will stay consistent without extra fixes.
// This input updates a signal on each keystroke.
import { signal } from '@preact/signals';
const name = signal('');
export function NameField() {
return (
<input
value={name.value}
onInput={e => { name.value = e.currentTarget.value; }}
/>
);
}
Precise state with signals
Fig:2 The signal feeds a computed value, an effect, and a view. Only readers update.
Signals automatically keep track of which components use a specific value and update only those parts when it changes. That means you don’t need extra wrappers like useMemo
or useCallback
to control rerenders.
Here’s a simple pattern that I’ve found clear and efficient in real projects.
// Signals for shared state, computed for derived values, effect for side work.
import { signal, computed, effect } from '@preact/signals';
const items = signal<{ id: string; price: number }[]>([]);
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0));
effect(() => {
window.dispatchEvent(new CustomEvent('cart_total', { detail: total.value }));
});
export function Cart() {
return (
<section>
<ul>
{items.value.map(i => <li key={i.id}>{i.price}</li>)}
</ul>
<strong>Total: {total}</strong>
</section>
);
}
The signal feeds a computed value, an effect, and a view. Only readers update.
Server HTML with suspense and islands
Fig:3 Islands hydrating after fallback resolves, without layout shifts.
If parts of the page wait on data, you can show a fallback and attach when the island resolves. The current work in Preact makes this smoother and removes some old layout constraints, which helps when you stream or mix static and live parts.
// This island may load later. The layout stays natural.
// A wrapper is not required when it becomes many nodes.
import { lazy, Suspense } from 'preact/compat';
const Profile = lazy(() => import('./Profile'));
export function Page() {
return (
<main>
<h1>Team</h1>
<Suspense fallback={<div>Loading profile…</div>}>
<Profile />
</Suspense>
</main>
);
}
Static HTML lands first, the fallback appears, the island resolves, then listeners attach.
Learning through contribution
While exploring Preact’s context and memo behavior during performance tests, I noticed a subtle memory pattern that kept detached nodes around longer than expected.
Fig:4 Memory retention issue and how the fix resolved detached nodes.
I raised it upstream, and the Preact maintainers quickly improved the commit path logic in v11. After the patch, heap snapshots stabilized exactly as they should.
That kind of responsiveness is one reason I like working with Preact, the core stays small, but the community moves fast.
Why Preact earns its place
- Same mental model as React, with less code shipped.
- Real DOM events, no synthetic layer.
- Fast hydration and smaller bundles.
- Straightforward state through Signals.
- Great compatibility when you need React libraries — and you can turn it off when you don’t.
If you’ve been building React apps and want the same comfort with better performance, Preact deserves a look.
Further reading
If you’d like to dig deeper into what’s mentioned above:
- Explore Preact’s official documentation — especially the parts on Signals, native events, and hydration.
- Check the @preact/preset-vite plugin for build setup and prerendering.
- Look at preact-render-to-string for server side rendering examples.
Top comments (0)