Preact to ShadowJS
Port Preact components, hooks and signals to ShadowJS with a codemod for the hooks and notes for render, class components, @preact/signals and server rendering.
Preact keeps React's component model, so a Preact app moves to ShadowJS the way a React app does: components run once instead of on every update, state is read by calling it, and dependency arrays disappear. This page covers what is different about Preact. Everything about hooks, props, context, events and forms is in React to ShadowJS, and the codemod below reuses its rules.
Project setup#
Same as React: a Vite app with the ShadowJS plugin, "jsx": "preserve" and "jsxImportSource": "@shadow-js/web" in
tsconfig.json, and no preact or preact/compat alias. Remove the Preact preset from the Vite configuration.
import shadow from "@shadow-js/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [shadow()] });
Automate it#
create-shadow-app codemod preact-to-shadow migrates the hooks from preact/hooks and preact/compat, and createContext and
Fragment from preact, with the rules of react-to-shadow: useState becomes useStore
or useSignal, useEffect(fn, []) becomes onMount(fn), useMemo and useCallback lose their dependency arrays. It parses each
file, edits by source range and prints what it left for you. Run it without a flag to see a diff and write nothing, then again
with --write. A second run changes nothing. See Migrate code with codemods
for the options and the exit codes.
npx create-shadow-app codemod preact-to-shadow src
npx create-shadow-app codemod preact-to-shadow src --write
It warns, with the file:line, for the Preact names it will not guess: render and hydrate (ShadowJS takes a function),
Component and PureComponent, h and createElement, useRef, useLayoutEffect, memo, forwardRef, and everything from
@preact/signals. The file keeps those imports and the rest is still migrated. This is the exact output for the input shown:
import { Fragment, render } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { signal } from "@preact/signals";
const visits = signal(0);
export function Counter({ start }: { start: number }) {
const [count, setCount] = useState(start);
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
input.current?.focus();
}, []);
const bump = useCallback(() => setCount(count + 1), [count]);
return (
<Fragment>
<button ref={input} onClick={bump}>
{count}
</button>
<p>{visits.value} visits</p>
</Fragment>
);
}
render(<Counter start={1} />, document.body);
import { Fragment } from "@shadow-js/web";
import { onMount, useSignal } from "@shadow-js/core";
import { render } from "preact";
import { useRef } from "preact/hooks";
import { signal } from "@preact/signals";
const visits = signal(0);
export function Counter({ start }: { start: number }) {
const [count, setCount] = useSignal(start);
const input = useRef<HTMLInputElement>(null);
onMount(() => {
input.current?.focus();
});
const bump = () => setCount(count() + 1);
return (
<Fragment>
<button ref={input} onClick={bump}>
{count()}
</button>
<p>{visits.value} visits</p>
</Fragment>
);
}
render(<Counter start={1} />, document.body);
The remaining Preact imports are the work left: render, useRef and signal each have a ShadowJS form, shown below.
What changes beyond React#
| Preact | ShadowJS |
|---|---|
render(<App />, root) |
render(() => <App />, root): the first argument is a function |
hydrate(<App />, root) |
hydrate(() => <App />, root) |
h(type, props, ...children) |
Write JSX; the compiler needs no h |
class Foo extends Component |
A function component; state is useSignal or useStore |
createRef() |
createRef() from @shadow-js/core, or a plain let |
toChildArray(children) |
children(() => props.children) |
useErrorBoundary() |
<ErrorBoundary> from @shadow-js/web |
preact-render-to-string |
renderToString and renderToStream from @shadow-js/web/server |
preact-iso, preact-router |
@shadow-js/router, or Dusk for file routes and a server |
import { onMount, useSignal } from "@shadow-js/core";
export function Counter(props: { start: number }) {
const [count, setCount] = useSignal(props.start);
let input: HTMLButtonElement | undefined;
onMount(() => input?.focus());
return (
<button ref={input} onClick={() => setCount(count() + 1)}>
{count()}
</button>
);
}
import { render } from "@shadow-js/web";
import { Counter } from "./counter";
render(() => <Counter start={1} />, document.body);
Signals#
@preact/signals and ShadowJS signals share an idea (reads subscribe, writes notify) and differ in shape. A Preact signal is
an object with a .value; a ShadowJS signal is an accessor and a setter. The codemod does not rewrite them, because .value
reads, writes and useSignal calls are spread through the code; each use is mechanical:
@preact/signals |
@shadow-js/core |
|---|---|
const count = signal(0) |
const [count, setCount] = useSignal(0) (inside a component) or the same call inside createRoot outside one |
count.value |
count() |
count.value = 1, count.value += 1 |
setCount(1), setCount((n) => n + 1) |
computed(() => count.value * 2) |
useMemo(() => count() * 2), read as double() |
effect(() => ...) |
useEffect(() => ...) in a component |
batch(() => ...) |
batch(() => ...) |
A signal that lives at module level in Preact (shared state) becomes a useStore in a module, or a context, in ShadowJS; see
State.
Server rendering#
preact-render-to-string and preact-ssr-prepass are replaced by the renderers of @shadow-js/web/server. renderToString is
synchronous; renderToStream streams the shell first and the content behind <Suspense> later, with no prepass.
import { renderToString } from "@shadow-js/web/server";
import { Counter } from "./counter";
export const html = renderToString(() => <Counter start={1} />);
Things that look like Preact but are not#
- No
preact/compatalias. ShadowJS has no React-compatible layer; React-only libraries need replacements, as listed in Migrating to ShadowJS. classandclassName. Both work;classNameis accepted as in Preact.- Events. Handlers are
onClickand the like;onInputandonChangefire as in React to ShadowJS. - Re-rendering. Nothing re-renders, so patterns that rely on it (reading a ref during render,
keyto reset a component) need the explicit forms described in the React guide.
See also#
- React to ShadowJS: hooks, effects, context and forms in depth.
- Migrating to ShadowJS: choosing a path and keeping the old app running.