Context7 — Live Docs
Before implementing, fetch current docs via Context7 MCP to avoid stale APIs:
resolve-library-id → "preact"
get-library-docs with resolved ID + your specific topic
When to Use
- Writing Preact functional components or custom hooks
- Importing hooks, types, or JSX utilities
- Configuring Rsbuild / Rslib / Rstest for Preact
- Setting up Module Federation shared config
- Using
forwardRef or any compat bridge
- Reviewing or fixing JSX transform issues
Architecture Rules (READ BEFORE WRITING ANY COMPONENT)
This project has a strict separation between shell (smart) and ui-components (dumb).
ui-components — display only
- NEVER import or create Zustand stores
- NEVER add business logic, auth, routing, or A/B testing
- MAY consume React Context — but the Provider ALWAYS lives in
shell
- Receives ALL data and callbacks via props or context from shell
- All dependencies are
peerDependencies — the component output bundles NOTHING
shell — smart layer
- Owns Zustand stores, Context providers, auth, routing, business logic
- Reads from Zustand, passes data DOWN to
ui-components via props or context
- Never lets
ui-components reach back up for data
State decision tree
Need global app state? → Zustand store in shell, passed as prop to component
Need to share across subtree? → Context provider in shell, useContext in component
Need local UI state? → useState / useReducer inside the component (fine in ui-components)
Need to trigger app logic? → Callback prop passed from shell to component
Critical Patterns
1. tsconfig — ALWAYS jsxImportSource: preact
Every tsconfig.json in the monorepo must include:
json
1{
2 "compilerOptions": {
3 "jsx": "react-jsx",
4 "jsxImportSource": "preact"
5 }
6}
Never use "jsxImportSource": "react". This project does NOT import the React namespace.
2. Imports — source of truth
ts
1// ✅ Hooks → preact/hooks
2import { useState, useEffect, useRef, useCallback, useMemo, useReducer, useContext, createContext } from "preact/hooks";
3
4// ✅ Core types and primitives → preact
5import { h, Fragment, createRef, cloneElement } from "preact";
6import type { FunctionalComponent, ComponentChildren, VNode, RefObject } from "preact";
7
8// ✅ forwardRef, memo, lazy, Suspense → preact/compat
9import { forwardRef, memo, lazy, Suspense } from "preact/compat";
10
11// ❌ NEVER — even though react is aliased, don't import it directly
12import React from "react";
13import { useState } from "react";
14import type { FC } from "react";
3. Functional Component pattern
tsx
1import type { FunctionalComponent, ComponentChildren } from "preact";
2import { useState } from "preact/hooks";
3
4interface CardProps {
5 title: string;
6 children: ComponentChildren;
7 onClose?: () => void;
8}
9
10export const Card: FunctionalComponent<CardProps> = ({ title, children, onClose }) => {
11 const [open, setOpen] = useState(true);
12
13 if (!open) return null;
14
15 return (
16 <div className="card">
17 <h2>{title}</h2>
18 <div>{children}</div>
19 {onClose && (
20 <button type="button" onClick={() => { setOpen(false); onClose(); }}>
21 Close
22 </button>
23 )}
24 </div>
25 );
26};
4. Hooks pattern
tsx
1import { useState, useEffect, useRef, useCallback } from "preact/hooks";
2import type { RefObject } from "preact";
3
4export function useDebounce<T>(value: T, delay: number): T {
5 const [debounced, setDebounced] = useState<T>(value);
6
7 useEffect(() => {
8 const timer = setTimeout(() => setDebounced(value), delay);
9 return () => clearTimeout(timer);
10 }, [value, delay]);
11
12 return debounced;
13}
14
15// useRef with type
16const inputRef: RefObject<HTMLInputElement> = useRef<HTMLInputElement>(null);
5. forwardRef pattern
tsx
1import { forwardRef } from "preact/compat";
2import type { Ref } from "preact";
3
4interface InputProps {
5 label: string;
6 value: string;
7 onChange: (value: string) => void;
8}
9
10export const Input = forwardRef<HTMLInputElement, InputProps>(
11 ({ label, value, onChange }, ref) => (
12 <label>
13 {label}
14 <input
15 ref={ref}
16 value={value}
17 onInput={(e) => onChange((e.target as HTMLInputElement).value)}
18 />
19 </label>
20 )
21);
6. Signals (optional, if @preact/signals is installed)
ts
1import { signal, computed, effect } from "@preact/signals";
2
3const count = signal(0);
4const doubled = computed(() => count.value * 2);
5
6effect(() => {
7 console.log("count changed:", count.value);
8});
9
10// In component — signal auto-subscribes on read
11export const Counter: FunctionalComponent = () => (
12 <button type="button" onClick={() => count.value++}>
13 {count} × 2 = {doubled}
14 </button>
15);
Types Reference
| Type | From | Usage |
|---|
FunctionalComponent<P> | preact | Function components (replaces React.FC) |
ComponentChildren | preact | children prop type (replaces React.ReactNode) |
VNode | preact | JSX element return type |
RefObject<T> | preact | Return type of useRef<T>() |
JSX.CSSProperties | preact | Inline style object (replaces React.CSSProperties) |
Ref<T> | preact | Accepts both callback refs and RefObject<T> |
ComponentType<P> | preact | Union of FC and class component types |
Key Differences from React
| React | Preact equivalent |
|---|
React.FC<P> | FunctionalComponent<P> from preact |
React.ReactNode | ComponentChildren from preact |
React.CSSProperties | JSX.CSSProperties from preact |
import { useState } from "react" | import { useState } from "preact/hooks" |
import { forwardRef } from "react" | import { forwardRef } from "preact/compat" |
class attribute | Both class and className work (compat normalizes) |
React.createElement | h from preact (but rarely needed directly) |
React.Fragment | Fragment from preact or <>...</> shorthand |
Module Federation — Singleton (CRITICAL)
Preact must be configured as singleton in all MF hosts and remotes. Duplicate Preact runtimes cause hooks to silently break.
ts
1// rsbuild.config.ts / rslib.config.ts
2import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";
3
4export default {
5 plugins: [
6 pluginModuleFederation({
7 name: "my_app",
8 shared: {
9 preact: {
10 singleton: true,
11 requiredVersion: "^10.0.0",
12 },
13 "preact/hooks": {
14 singleton: true,
15 requiredVersion: "^10.0.0",
16 },
17 "preact/compat": {
18 singleton: true,
19 requiredVersion: "^10.0.0",
20 },
21 "preact/jsx-runtime": {
22 singleton: true,
23 requiredVersion: "^10.0.0",
24 },
25 },
26 }),
27 ],
28};
Build Config — pluginPreact() is mandatory
pluginPreact() must be present in every rsbuild.config.ts, rslib.config.ts, and rstest.config.ts. Without it, JSX transform breaks and HMR won't work correctly.
ts
1// rsbuild.config.ts
2import { defineConfig } from "@rsbuild/core";
3import { pluginPreact } from "@rsbuild/plugin-preact";
4
5export default defineConfig({
6 plugins: [pluginPreact()],
7});
ts
1// rstest.config.ts
2import { defineConfig } from "@rstest/core";
3import { pluginPreact } from "@rsbuild/plugin-preact";
4
5export default defineConfig({
6 plugins: [pluginPreact()],
7 // ... test config
8});
Commands
bash
1bun run dev # dev server with Preact HMR
2bun run test # runs rstest with pluginPreact()
3bun run build # rslib/rsbuild build with Preact
4bun run typecheck # tsc --noEmit — validates jsxImportSource
Suspense + Refs — Critical Timing Gotcha
NEVER place a ref target element inside a <Suspense> boundary if a hook in the parent component depends on that ref in a useEffect.
When a component wraps lazy children in <Suspense>, the children don't exist in the DOM until the lazy imports resolve. But the parent's useEffect runs immediately on mount — when ref.current is still null. Since ref is a stable object identity, the effect never re-runs, and any hook that attaches listeners or observers (useSwipe, useFocusTrap, useClickOutside, IntersectionObserver, ResizeObserver, etc.) silently fails with zero errors.
tsx
1// ❌ BAD — ref is null when useEffect runs, listeners never attached
2const MyComponent: FunctionalComponent = () => {
3 const contentRef = useRef<HTMLDivElement>(null);
4 useSwipe(contentRef, { onSwipeLeft: goNext }); // effect runs, ref.current is null → silent no-op
5
6 return (
7 <Suspense fallback={<Skeleton />}>
8 <LazyChild>
9 <div ref={contentRef}>content</div> {/* doesn't exist until lazy resolves */}
10 </LazyChild>
11 </Suspense>
12 );
13};
14
15// ✅ GOOD — ref target is outside Suspense, available immediately
16const MyComponent: FunctionalComponent = () => {
17 const contentRef = useRef<HTMLDivElement>(null);
18 useSwipe(contentRef, { onSwipeLeft: goNext }); // effect runs, ref.current exists ✓
19
20 return (
21 <div ref={contentRef}> {/* exists on mount, events from children bubble up */}
22 <Suspense fallback={<Skeleton />}>
23 <LazyChild>content</LazyChild>
24 </Suspense>
25 </div>
26 );
27};
Key points:
- Suspense can wrap multiple lazy components (one skeleton for all is fine)
- Only ref targets that hooks depend on need to be outside the boundary
- Child DOM events (touch, click, keyboard) naturally bubble up to the parent ref element
- This bug is completely silent — no errors, no warnings, the feature just doesn't work
Common Mistakes to Avoid
- Importing hooks from
"react" — fails silently or throws; always use "preact/hooks"
- Missing
pluginPreact() in rstest — tests can't parse JSX
- Duplicate Preact in MF — hooks state is lost across remote boundary; always
singleton: true
- Using
React.FC — use FunctionalComponent<P> from preact instead
jsxImportSource: "react" in any tsconfig — breaks the entire JSX transform for that package
- Ref target inside
<Suspense> — ref is null when useEffect runs; listeners never attached (see section above)