Preact
Purpose
Build ultra-lightweight Preact applications using signals for reactive state, compat layer for React interop, and hooks for component logic — all under 3kB.
Agent Protocol
Trigger
Exact user phrases: "Preact setup", "Preact signal", "Preact hooks", "Preact project", "Preact vs React", "Preact SSR", "preact app".
Input Context
Before activating, verify:
- package.json has preact dependency (or preact/compat).
- Whether the project uses Vite, WMR, or manual bundler.
- If React interop is needed (preact/compat).
Output Artifact
No file output. Produces code snippets, config examples, and structural guidance as text.
Response Format
Config:
// vite.config.ts
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
export default defineConfig({ plugins: [preact()] })
Code: show component, signal, and hook definitions inline. No import statements.
No preamble. No postamble. No explanations. Compress output — why use many token when few do trick.
Completion Criteria
- Entry point uses
render()from preact (not ReactDOM). - Stateful logic uses signals (@preact/signals) for local/global state.
- Hooks follow Preact compatibility (useEffect, useState, useMemo all work).
- JSX uses
hpragma or compat layer. - Bundle size is monitored — no heavy React-for-Preact swaps.
- SSR uses preact-render-to-string.
- Class components avoided (functional + hooks preferred).
Max Response Length
~4096 tokens.
Architecture Decision Trees
State Management Decision
What type of state?
Local UI state (toggle, input) -> useSignal() from @preact/signals
Shared across few siblings -> Props lifting + signals
Global application state -> Signals in a shared module file
Server/async state -> useSWR or TanStack Query (via compat)
Complex form state -> useReducer from preact/hooks
React library using state? -> Use preact/compat aliasing + their API
React Compatibility Decision
Does the project use React ecosystem libraries?
No -> Use Preact natively (smallest bundle)
Yes -> What libraries?
React Router -> preact/compat aliasing works
TanStack Query -> preact/compat aliasing works
Radix UI -> preact/compat may have issues — test first
Framer Motion -> Known compat issues — consider alternatives
Workflow
Step 1: Setup with Vite
// vite.config.ts
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
export default defineConfig({
plugins: [preact()]
})
// src/main.tsx
import { render } from 'preact'
import { App } from './app'
render(<App />, document.getElementById('app')!)
Step 2: Signals for State
import { signal, computed } from '@preact/signals'
const count = signal(0)
const doubled = computed(() => count.value * 2)
function Counter() {
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button => count.value++}>+1</button>
</div>
)
}
Step 3: Signal Patterns
// Global signals in module
// store/cart.ts
import { signal, computed } from '@preact/signals'
export const cartItems = signal<CartItem[]>([])
export const cartCount = computed(() => cartItems.value.reduce((sum, i) => sum + i.qty, 0))
export const cartTotal = computed(() => cartItems.value.reduce((sum, i) => sum + i.price * i.qty, 0))
export function addItem(item: CartItem) {
cartItems.value = [...cartItems.value, item]
}
// Signals with side effects
import { effect } from '@preact/signals'
effect(() => {
console.log('Cart updated:', cartItems.value)
localStorage.setItem('cart', JSON.stringify(cartItems.value))
})
Step 4: Hooks (React-compatible)
import { useState, useEffect, useMemo } from 'preact/hooks'
function Timer() {
const [seconds, setSeconds] = useState(0)
useEffect(() => {
const id = setInterval(() => setSeconds(s => s + 1), 1000)
return () => clearInterval(id)
}, [])
return <div>{seconds}s elapsed</div>
}
Step 5: Preact Compat (React interop)
// Replace React imports with preact/compat
import React from 'preact/compat'
import ReactDOM from 'preact/compat'
// Or in vite.config:
// resolve: { alias: { react: 'preact/compat', 'react-dom': 'preact/compat' } }
Step 6: SSR
import { render } from 'preact-render-to-string'
import { App } from './app'
const html = render(<App />)
// Inject into HTML template and serve
Step 7: Code Splitting
import { lazy, Suspense } from 'preact/compat'
const Dashboard = lazy(() => import('./dashboard'))
const Settings = lazy(() => import('./settings'))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
)
}
Common Pitfalls
- Using ReactDOM APIs: Preact uses
render()frompreact, notReactDOM.createRoot(). - Missing compat alias: Without preact/compat aliasing, React-ecosystem libraries import React directly.
- Signals in JSX without values:
{count}in JSX works (automatic.valueaccess), but in hooks/effects usecount.value. - Class components: Preact supports them via compat, but functional + hooks + signals is preferred.
- Over-using compat: Using preact/compat loses the bundle-size benefit of Preact. Use native Preact APIs when possible.
- Switching from useState to signals mid-project: Mixed state patterns cause confusion. Pick one per feature.
- Not tree-shaking compat: compat adds ~2KB — only add it when needed.
- Forgetting SSR hydration: preact-render-to-string on server, hydrate on client with
hydrate().
Best Practices
- Use
render()from preact, neverReactDOM.createRoot(). - Prefer signals (
@preact/signals) over useState for shared state. - Aliasing react to preact/compat in bundler config is required for React-ecosystem libs.
- Functional components only — no class components.
- Keep component files under 100 lines.
- Use
preact/hooksfor lifecycle effects. - Avoid
createContextwhen signals provide simpler reactivity. - Bundle target: keep preact-specific code under 3kB total.
Compared With
| Aspect | Preact | React | SolidJS |
|---|---|---|---|
| Bundle size | ~3KB | ~45KB | ~8KB |
| Signals | Built-in (@preact/signals) | External | Built-in (createSignal) |
| VDOM | Yes, lightweight | Full | No (compiled) |
| React compat | preact/compat | Native | N/A |
| Hooks | Compatible subset | Full | Different API |
| SSR | preact-render-to-string | react-dom/server | solid-ssr |
Preact vs React
Preact is a React-compatible library at 1/15th the size. It supports most React APIs (hooks, JSX, Context) but lacks React 18's concurrent features and some edge cases. Use Preact when bundle size is critical and advanced React features are not needed.
Preact vs SolidJS
Both are lightweight alternatives to React. SolidJS avoids VDOM entirely for fine-grained updates; Preact keeps a lightweight VDOM for React compatibility. Preact is the drop-in replacement; SolidJS requires rewriting components.
Performance
Bundle Size Advantage
- Preact core: ~3KB gzipped (vs React ~45KB).
- Compat layer: ~2KB additional.
- Signals: ~1KB.
- Total Preact + signals: ~4KB — 10x smaller than React alone.
Rendering Performance
- Preact 10+ uses a modern diff algorithm similar to React but lighter.
- Signals provide fine-grained updates — only components reading the signal re-render.
- Compat mode adds overhead (~2KB, ~10% performance hit).
- Preact/compat aliases VDOM operations through an extra abstraction layer.
Optimization Techniques
- Use signals instead of useState for frequently updated state (avoids component re-render).
- Batch signal updates within the same microtask.
- Use
useCallbackanduseMemosparingly — Preact's diff is cheaper than React's. preact/compathasPureComponentandReact.memosupport.- Lazy-load route components with
lazy()+Suspense(via compat).
Testing Strategies
Unit Testing with Vitest
import { render, screen, fireEvent } from '@testing-library/preact'
import { Counter } from './Counter'
test('renders counter and increments', () => {
render(<Counter />)
expect(screen.getByText('Count: 0')).toBeTruthy()
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('Count: 1')).toBeTruthy()
})
Signal Testing
import { signal, computed } from '@preact/signals'
test('signal computed values', () => {
const count = signal(0)
const doubled = computed(() => count.value * 2)
expect(doubled.value).toBe(0)
count.value = 5
expect(doubled.value).toBe(10)
})
Key Testing Practices
- Use
@testing-library/preactfor component tests. - Test signals independently of components (pure logic).
- Use
act()from preact/test-utils for async rendering. - Test compat mode by importing from
preact/compatin tests. - SSR test: render to string and assert HTML output.
Migration Patterns
From React to Preact
| React | Preact |
|---|---|
import React from 'react' |
import { h } from 'preact' or compat |
ReactDOM.createRoot().render() |
render() from preact |
useState |
useState from preact/hooks or signals |
createContext |
createContext from preact or signals |
React.lazy |
lazy() from preact/compat |
React.memo |
memo() from preact/compat |
Migration steps: 1) Install preact + @preact/preset-vite, 2) Add alias in vite.config, 3) Replace react-dom imports with preact, 4) Add preact/compat for remaining React libs, 5) Verify bundle size reduction.
From Vue/Options API to Preact
| Vue Concept | Preact Equivalent |
|---|---|
ref() |
signal() |
computed() |
computed() from @preact/signals |
watch() |
effect() from @preact/signals |
v-if |
{condition && <Component/>} |
v-for |
{items.map(i => <Item />)} |
| Props | Component props argument |
Build and Bundle Considerations
- Vite plugin:
@preact/preset-viteauto-configures JSX pragma. - Aliasing:
resolve.alias = { react: 'preact/compat', 'react-dom': 'preact/compat' }. - WMR: Preact's own dev server (alternative to Vite).
- Production builds: Vite's default build tree-shakes unused compat features.
- Bundle analysis:
vite-plugin-bundle-analyzerto verify preact/compat usage. - SSR: Use
preact-render-to-stringfor Node.js rendering,prerenderfor static sites. - ES modules: Preact ships ESM and UMD bundles.
Tooling
- Preact DevTools browser extension — inspect component tree, hooks, signals.
@preact/preset-vite— official Vite preset with HMR and alias config.preact-render-to-string— SSR rendering for Preact.@testing-library/preact— component testing utilities.preact-ssr-prepass— data prefetching for SSR (Apollo, TanStack Query).preact-iso— routing and lazy loading for Preact SPA without compat.preact-hooks-testing-library— hook testing utilities.- Size comparison:
npx preact sizecompares Preact vs React bundle size.
Ecosystem
Preact-Compatible React Libraries
| Library | Compat Status |
|---|---|
| React Router | Full compat |
| TanStack Query | Full compat |
| Zustand | Full compat |
| React Hook Form | Mostly works |
| Radix UI | Some issues |
| Framer Motion | Known issues |
Preact-Native Libraries
- preact-iso — Routing and lazy loading.
- preact-router — Simple routing (legacy).
- htm — Hyperscript Tagged Markup (JSX alternative).
Rules
- Use
render()from preact, neverReactDOM.createRoot(). - Prefer signals (
@preact/signals) over useState for shared state. - Aliasing react to preact/compat in bundler config is required for React-ecosystem libs.
- Functional components only — no class components.
- Keep component files under 100 lines.
- Use
preact/hooksfor lifecycle effects. - Avoid
createContextwhen signals provide simpler reactivity. - Bundle target: keep preact-specific code under 3kB total.
References
- references/preact-advanced.md — Preact Advanced Topics
- references/preact-architecture.md — Preact Architecture Patterns
- references/preact-deployment.md — Preact Deployment
- references/preact-fundamentals.md — Preact Fundamentals
- references/preact-setup.md — Preact Setup Guide
- references/preact-vs-react.md — Preact vs React: Differences & Migration
Handoff
No artifact produced. Next skill: preact-ssr (if SSR needed) or frontend-testing. Carry forward: signal-based reactivity, hooks conventions, tiny-bundle mindset.
Implementation Patterns
Signal-Based State
import { signal, computed, effect, Signal } from '@preact/signals';
// Reactive state
const count = signal(0);
const name = signal('world');
// Computed values (auto-derived)
const greeting = computed(() => `Hello, ${name.value}!`);
const doubled = computed(() => count.value * 2);
// Effects (reactions to state changes)
effect(() => {
console.log(`Count changed to: ${count.value}`);
});
// In a component
function Counter() {
return (
<div>
<p>{greeting.value}</p>
<p>Count: {count.value} (doubled: {doubled.value})</p>
<button => count.value++}>+</button>
<button => count.value--}>-</button>
</div>
);
}
Optimized Component Pattern
import { h, Fragment, ComponentChildren } from 'preact';
import { useRef, useCallback, useEffect } from 'preact/hooks';
// Use memo for expensive renders
import { memo } from 'preact/compat';
interface ListItemProps {
id: string;
label: string;
onSelect: (id: string) => void;
}
const ListItem = memo(({ id, label, onSelect }: ListItemProps) => {
const handleClick = useCallback(() => onSelect(id), [id, onSelect]);
return <li
});
// Portal integration
import { createPortal } from 'preact/compat';
function Modal({ children }: { children: ComponentChildren }) {
const container = useRef(document.getElementById('modal-root')!);
return createPortal(<div class="modal-overlay">{children}</div>, container.current);
}
// Suspense-compatible lazy loading
import { lazy, Suspense } from 'preact/compat';
const HeavyComponent = lazy(() => import('./heavy-component'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
Anti-Patterns
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| Using compat for everything | Bundle grows to React size | Use Preact APIs directly, compat for exceptions |
| Signals everywhere | Overkill for simple local state | useState for component-local, signals for shared |
| Not aliasing in bundler | Importing react instead of preact | Configure resolve.alias in bundler |
| Class components with compat | Unnecessary weight | Functional components + hooks |
| Mixing preact/compat and preact/hooks | Hook compatibility issues | Consistently use one or the other |
Performance Optimization
- Signal-based reactivity over re-renders: Signals only re-render the specific DOM nodes that depend on them. No virtual DOM diff for the parent component. 10x faster for fine-grained updates.
- Aliasing react to preact/compat: Configure in bundler (vite, webpack) to redirect
reactimports topreact/compat. Enables using React ecosystem with Preact's small bundle size. - No synthetic event pooling: Preact doesn't pool events like React does. No nullification of event properties after callback. Improves performance for event-heavy components.
Production Considerations
Deployment Checklist
- Production build with optimizations enabled
- Environment variables configured per environment
- Health check endpoint responds correctly
- Error tracking and monitoring integrated
- Logging level configured (not debug in production)
- Resource limits configured
- Database migrations applied
- Static assets built and served from CDN or cache
- Feature flags toggled appropriately
- Rollback plan documented and tested
Monitoring and Alerting
| Metric | Threshold | Severity | Action |
|---|---|---|---|
| Error rate | > 1% | Critical | Rollback or fix |
| p95 latency | > 500ms | Warning | Profile and optimize |
| Uptime | < 99.9% | Critical | Investigate infrastructure |
| Memory usage | > 80% | Warning | Check for leaks |
| CPU usage | > 80% | Warning | Scale up or optimize |
Security Considerations
- DangerousHtml sanitization: Preact doesn't auto-sanitize
dangerouslySetInnerHTML. Always sanitize HTML content through DOMPurify before injecting. Never set__htmlfrom untrusted sources. - Signal expression safety: Signal values in JSX auto-escape via Preact's diffing. However, signals in
innerHTMLbypass JSX escaping. Always use{signal.value}in JSX, never construct raw HTML strings. - XSS via compat:
preact/compatmay expose React patterns likecreateElementwith dangerouslySetInnerHTML. Audit compat-using components for injection vectors. Use PropTypes or TypeScript runtime checks for user input. - Third-party script isolation: Preact apps embedded in third-party sites must handle CSS/JS conflicts. Use shadow DOM for widget components via
preact-shadow-rootor custom elements. Isolate state from host page globals. - Input validation: Always validate and sanitize user inputs before rendering. Use Preact's built-in escaping through JSX expressions
{value}. For rich text rendering, use a dedicated component with DOMPurify integration and never bypass JSX escaping withinnerHTML. - Dependency audit: Preact's small API surface reduces attack surface but
preact/compatpulls in more code. Audit compat dependencies for known vulnerabilities. Keep Preact and compat versions in sync to avoid security patch gaps.