NexusCS

Preact

Web Frameworks
Fast 3kB alternative to React with the same modern API. Preact provides the thinnest possible Virtual DOM abstraction on top of the real DOM.
react
javascript
jsx
hooks
signals

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`
      <div>
        <h1>Hello from HTM!</h1>
        <p>No build step required</p>
      </div>
    `;
  }

  render(html`<${App} />`, 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