Getting started
Introduction
Preact is a fast 3kB React alternative with the same modern API. It's highly compatible with React but optimized for size and performance.
# Bundle sizes (minified + gzipped)
Preact: 3kB
React + DOM: 45kB
Installation with Vite
# Create new Preact project
npm create vite@latest my-app -- --template preact
cd my-app
npm install
npm run dev
Manual installation
# Install Preact
npm install preact
# Optional: Preact Router
npm install preact-router
# Optional: Signals
npm install @preact/signals
Quick example
import { render } from "preact";
function App() {
return <h1>Hello from Preact!</h1>;
}
render(<App />, document.getElementById("app"));
Components
Function components
function Greeting({ name, age }) {
return (
<div>
<h1>Hello, {name}!</h1>
<p>Age: {age}</p>
</div>
);
}
// Usage
<Greeting name="Alice" age={25} />;
Function with children
function Card({ title, children }) {
return (
<div class="card">
<h2>{title}</h2>
<div class="content">{children}</div>
</div>
);
}
// Usage
<Card title="Profile">
<p>Card content here</p>
</Card>;
Class components
import { Component } from "preact";
class Counter extends Component {
state = { count: 0 };
increment = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.increment}>+1</button>
</div>
);
}
}
Lifecycle methods
class MyComponent extends Component {
componentDidMount() {
// After initial render
}
componentDidUpdate(prevProps, prevState) {
// After updates
}
componentWillUnmount() {
// Before removal
}
render() {
return <div>Component</div>;
}
}
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>
</div>
);
}
useEffect
import { useEffect } from "preact/hooks";
function Timer() {
const [time, setTime] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setTime((t) => t + 1);
}, 1000);
// Cleanup function
return () => clearInterval(id);
}, []); // Empty deps = run once
return <p>Seconds: {time}</p>;
}
useRef
import { useRef } from "preact/hooks";
function TextInput() {
const inputRef = useRef(null);
const focus = () => {
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focus}>Focus</button>
</div>
);
}
useContext
import { createContext } from "preact";
import { useContext } from "preact/hooks";
const ThemeContext = createContext("light");
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button class={theme}>Themed</button>;
}
// Provider
<ThemeContext.Provider value="dark">
<ThemedButton />
</ThemeContext.Provider>;
useMemo
import { useMemo } from "preact/hooks";
function ExpensiveList({ items, filter }) {
const filtered = useMemo(() => {
return items.filter((item) => item.name.includes(filter));
}, [items, filter]);
return <ul>{filtered.map(/* ... */)}</ul>;
}
useCallback
import { useCallback } from "preact/hooks";
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount((c) => c + 1);
}, []); // Stable reference
return <Child onClick={handleClick} />;
}
useReducer
import { useReducer } from "preact/hooks";
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
</div>
);
}
Differences from React
Event handling
// React
<input onChange={e => setValue(e.target.value)} />
// Preact (more native)
<input onInput={e => setValue(e.target.value)} />
// Both work in Preact
<input onChange={...} /> // Also works
<input onInput={...} /> // Native event
Class vs className
// React: className
<div className="container">Content</div>
// Preact: class OR className (both work)
<div class="container">Content</div>
<div className="container">Content</div>
SVG attributes
// React: camelCase
<svg>
<circle strokeWidth="2" />
</svg>
// Preact: kebab-case (native HTML)
<svg>
<circle stroke-width="2" />
</svg>
// Both work in Preact
<circle strokeWidth="2" />
<circle stroke-width="2" />
htmlFor vs for
// React
<label htmlFor="input">Label</label>
// Preact: for OR htmlFor
<label for="input">Label</label>
<label htmlFor="input">Label</label>
defaultValue vs value
// Uncontrolled input
<input defaultValue="initial" />;
// Controlled input
const [val, setVal] = useState("");
<input value={val} onInput={(e) => setVal(e.target.value)} />;
Signals
Basic signals
import { signal } from "@preact/signals";
// Create signal
const count = signal(0);
// Read value
console.log(count.value); // 0
// Update value
count.value = 1;
// Use in component (auto-subscribes)
function Counter() {
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
}
Computed signals
import { signal, computed } from "@preact/signals";
const count = signal(0);
const double = computed(() => count.value * 2);
function Display() {
return (
<div>
<p>Count: {count}</p>
<p>Double: {double}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
}
Effects with signals
import { signal, effect } from "@preact/signals";
const name = signal("Alice");
// Runs when dependencies change
effect(() => {
console.log(`Hello, ${name.value}!`);
});
// Update triggers effect
name.value = "Bob"; // Logs: "Hello, Bob!"
Global state
// store.js
import { signal } from "@preact/signals";
export const user = signal(null);
export const isLoggedIn = computed(() => user.value !== null);
// Component.jsx
import { user, isLoggedIn } from "./store";
function Profile() {
if (!isLoggedIn.value) {
return <p>Not logged in</p>;
}
return <p>Welcome, {user.value.name}!</p>;
}
Signal with objects
import { signal } from "@preact/signals";
const user = signal({
name: "Alice",
age: 25,
});
// Update entire object
user.value = { name: "Bob", age: 30 };
// Update property (replace object)
user.value = {
...user.value,
age: 26,
};
// Use in component
function Profile() {
return (
<p>
{user.value.name}, {user.value.age}
</p>
);
}
Why signals?
// Traditional state: re-renders entire component
function App() {
const [count, setCount] = useState(0);
console.log("Render"); // Logs on every update
return <p>Count: {count}</p>;
}
// Signals: surgical updates, no re-render
const count = signal(0);
function App() {
console.log("Render"); // Logs once
return <p>Count: {count}</p>; // Updates without re-render
}
Routing
preact-router
npm install preact-router
import Router from "preact-router";
function App() {
return (
<Router>
<Home path="/" />
<Profile path="/profile/:user" />
<NotFound default />
</Router>
);
}
function Home() {
return <h1>Home</h1>;
}
function Profile({ user }) {
return <h1>User: {user}</h1>;
}
preact-iso (modern)
npm install preact-iso
import { LocationProvider, Router, Route } from "preact-iso";
function App() {
return (
<LocationProvider>
<Router>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/user/:id" component={User} />
</Router>
</LocationProvider>
);
}
Navigation
import { useLocation } from "preact-iso";
function Nav() {
const location = useLocation();
return (
<nav>
<a href="/" onClick={location.route}>
Home
</a>
<a href="/about" onClick={location.route}>
About
</a>
</nav>
);
}
// Programmatic navigation
function Login() {
const location = useLocation();
const handleSubmit = () => {
// ... login logic
location.route("/dashboard");
};
return <form onSubmit={handleSubmit}>...</form>;
}
Lazy loading routes
import { lazy } from "preact-iso";
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
function App() {
return (
<Router>
<Route path="/" component={Home} />
<Route path="/dashboard" component={Dashboard} />
<Route path="/settings" component={Settings} />
</Router>
);
}
Server-Side Rendering
Basic SSR
import { render } from "preact";
import renderToString from "preact-render-to-string";
// Server
const html = renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html>
<body>
<div id="app">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`);
Hydration
// Client entry point
import { hydrate } from "preact";
// Use hydrate instead of render for SSR
hydrate(<App />, document.getElementById("app"));
Streaming SSR
import { renderToStringAsync } from "preact-render-to-string";
// Async SSR with Suspense
const html = await renderToStringAsync(<App />);
SSR with preact-iso
import { prerender } from "preact-iso";
export async function prerenderRoutes() {
const routes = ["/", "/about", "/contact"];
for (const route of routes) {
const { html } = await prerender(<App />, {
url: route,
});
// Save html to file
}
}
Size Optimization
Tree shaking
// Vite config
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ["preact", "preact/hooks"],
},
},
},
},
};
Lazy loading components
import { lazy, Suspense } from "preact/compat";
const Heavy = lazy(() => import("./Heavy"));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Heavy />
</Suspense>
);
}
Code splitting
// Dynamic imports
const loadModule = async () => {
const module = await import("./utils");
module.doSomething();
};
// Route-based splitting
const routes = [
{
path: "/",
component: () => import("./Home"),
},
{
path: "/about",
component: () => import("./About"),
},
];
Bundle analysis
# Analyze bundle size
npm install -D rollup-plugin-visualizer
# Vite config
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({ open: true })
]
};
Preact vs React size
# Production bundle sizes (gzipped)
preact: 3 KB
preact + hooks: 4 KB
preact + router: 5 KB
preact + signals: 5 KB
react: 45 KB
react + react-dom: 45 KB
react + react-router: 55 KB
Compat Mode
Setup preact/compat
npm install preact
// vite.config.js
export default {
resolve: {
alias: {
react: "preact/compat",
"react-dom": "preact/compat",
},
},
};
Webpack alias
// webpack.config.js
module.exports = {
resolve: {
alias: {
react: "preact/compat",
"react-dom/test-utils": "preact/test-utils",
"react-dom": "preact/compat",
"react/jsx-runtime": "preact/jsx-runtime",
},
},
};
Using React libraries
// React library imports work automatically
import { useState } from "react";
import ReactMarkdown from "react-markdown";
function App() {
const [text, setText] = useState("# Hello");
return (
<div>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<ReactMarkdown>{text}</ReactMarkdown>
</div>
);
}
Compatibility notes
// Most React libraries work
// Compatible:
- react-router
- react-query
- react-hook-form
- styled-components
- emotion
// Not compatible:
- Libraries using React internals
- React Server Components
- React Native
Build Tools
Vite configuration
// vite.config.js
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
export default defineConfig({
plugins: [preact()],
esbuild: {
jsxFactory: "h",
jsxFragment: "Fragment",
},
});
Babel configuration
{
"presets": [
[
"@babel/preset-env",
{
"targets": { "esmodules": true }
}
]
],
"plugins": [
[
"@babel/plugin-transform-react-jsx",
{
"pragma": "h",
"pragmaFrag": "Fragment"
}
]
]
}
TypeScript configuration
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"lib": ["DOM", "ES2015"],
"module": "ESNext",
"target": "ES2015",
"moduleResolution": "node"
}
}
Environment setup
# Project structure
my-app/
├── src/
│ ├── index.jsx
│ ├── App.jsx
│ └── components/
├── public/
├── package.json
├── vite.config.js
└── index.html
HTM (Hyperscript Tagged Markup)
No build step
<script type="module">
import { html, render } from "https://esm.sh/htm/preact";
function App() {
return html``;
}
render(html``, document.body);
</script>
With components
import { html } from "htm/preact";
import { useState } from "preact/hooks";
function Counter() {
const [count, setCount] = useState(0);
return html`
<div>
<p>Count: ${count}</p>
<button onClick=${() => setCount(count + 1)}>Increment</button>
</div>
`;
}
HTM benefits
- No JSX transpilation needed
- Works in browser natively
- ES modules from CDN
- Great for prototypes
- Slightly larger than JSX
Testing
With @testing-library
npm install -D @testing-library/preact @testing-library/jest-dom
import { render, fireEvent } from "@testing-library/preact";
import { expect, test } from "vitest";
import Counter from "./Counter";
test("increments counter", () => {
const { getByText } = render(<Counter />);
const button = getByText("Increment");
fireEvent.click(button);
expect(getByText("Count: 1")).toBeInTheDocument();
});
Component testing
import { render } from "@testing-library/preact";
import { expect, test } from "vitest";
test("renders greeting", () => {
const { container } = render(<Greeting name="Alice" />);
expect(container.textContent).toBe("Hello, Alice!");
});
Hook testing
import { renderHook, act } from "@testing-library/preact";
import { useCounter } from "./useCounter";
test("useCounter increments", () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Common Patterns
Higher-Order Component
function withAuth(Component) {
return function AuthWrapper(props) {
const { user } = useContext(AuthContext);
if (!user) {
return <Redirect to="/login" />;
}
return <Component {...props} />;
};
}
const ProtectedPage = withAuth(Dashboard);
Render props
function DataProvider({ render }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, []);
return render(data);
}
// Usage
<DataProvider
render={(data) => <div>{data ? <List items={data} /> : "Loading"}</div>}
/>;
Custom hooks
function useLocalStorage(key, initial) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Usage
function App() {
const [name, setName] = useLocalStorage("name", "Guest");
return <input value={name} onInput={(e) => setName(e.target.value)} />;
}
Error boundaries
import { Component } from "preact";
class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, errorInfo) {
console.error("Error caught:", error, errorInfo);
}
render() {
if (this.state.error) {
return <div>Error: {this.state.error.message}</div>;
}
return this.props.children;
}
}
// Usage
<ErrorBoundary>
<App />
</ErrorBoundary>;
Performance Tips
Avoid inline functions
// Bad: creates new function every render
<button onClick={() => handleClick(id)}>Click</button>;
// Good: stable reference
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
<button onClick={handleClick}>Click</button>;
Memo components
import { memo } from "preact/compat";
// Only re-renders if props change
const ExpensiveComponent = memo(({ data }) => {
return <div>{/* expensive render */}</div>;
});
// With custom comparison
const CustomMemo = memo(Component, (prev, next) => {
return prev.id === next.id;
});
Use signals for state
// Traditional: re-renders on every update
const [items, setItems] = useState([]);
// Signals: surgical updates
const items = signal([]);
items.value = [...items.value, newItem];
Key prop in lists
// Good: stable keys
{
items.map((item) => <Item key={item.id} {...item} />);
}
// Bad: index as key (causes issues on reorder)
{
items.map((item, i) => <Item key={i} {...item} />);
}
Gotchas
Event differences
// Preact uses native events
function Input() {
// Use onInput for real-time updates
return <input onInput={(e) => console.log(e.target.value)} />;
// onChange fires on blur
return <input onChange={(e) => console.log(e.target.value)} />;
}
Class vs className
// Both work, but 'class' is smaller
<div class="container">Preferred</div>
<div className="container">Also works</div>
SVG attribute names
// Use native HTML attribute names
<svg>
<circle stroke-width="2" />
</svg>
// camelCase also works (compat)
<svg>
<circle strokeWidth="2" />
</svg>
Controlled inputs
// Must provide both value and handler
const [val, setVal] = useState('');
<input
value={val}
onInput={e => setVal(e.target.value)}
/>
// Or use uncontrolled with defaultValue
<input defaultValue="initial" />
Migration from React
Step 1: Install Preact
npm uninstall react react-dom
npm install preact
Step 2: Update imports
// Before
import React, { useState } from "react";
import ReactDOM from "react-dom";
// After
import { h } from "preact";
import { useState } from "preact/hooks";
import { render } from "preact";
Step 3: Use compat (easier)
// vite.config.js
export default {
resolve: {
alias: {
react: "preact/compat",
"react-dom": "preact/compat",
},
},
};
// No code changes needed!
Step 4: Update events
// React
<input onChange={handler} />
// Preact
<input onInput={handler} />
// Or use compat mode (onChange works)
Step 5: Update class names
// React
<div className="container" />
// Preact (both work)
<div class="container" />
<div className="container" />
Step 6: Test thoroughly
# Run tests
npm test
# Check bundle size
npm run build
# Compare sizes
du -h dist/
DevTools
Preact DevTools
# Install browser extension
# Chrome: https://chrome.google.com/webstore
# Firefox: https://addons.mozilla.org
# Search: "Preact Developer Tools"
Debug mode
// Enable debug warnings
import "preact/debug";
// Must be imported before preact
// Shows warnings for common mistakes
Profiling
import { options } from "preact";
// Custom profiler
options.debounceRendering = requestIdleCallback;
// Log render times
let start;
options._diff = () => {
start = performance.now();
};
options.diffed = () => {
console.log("Render time:", performance.now() - start);
};
Also see
- Preact Official Docs - Official documentation
- Preact GitHub - Source code and issues
- Preact CLI - Official CLI tool
- Preact Signals - Reactive state management
- Preact Router - Client-side routing
- Preact ISO - Modern routing with SSR
- HTM (Hyperscript Tagged Markup) - JSX alternative
- Vite Preact Template - Official Vite preset
- Preact vs React - Key differences
- Preact Compat - React compatibility layer