[]previous and next page

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.

For agentsUpdated

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.

vite.config.tsts
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.

sh
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:

counter.before.tsxtsx
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);
counter.after.tsxtsx
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
src/counter.tsxtsx
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>
  );
}
src/main.tsxtsx
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.

src/ssr.tsxtsx
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/compat alias. ShadowJS has no React-compatible layer; React-only libraries need replacements, as listed in Migrating to ShadowJS.
  • class and className. Both work; className is accepted as in Preact.
  • Events. Handlers are onClick and the like; onInput and onChange fire as in React to ShadowJS.
  • Re-rendering. Nothing re-renders, so patterns that rely on it (reading a ref during render, key to reset a component) need the explicit forms described in the React guide.

See also#