A tiny transform that turns let bindings into @preact/signals when you opt in with a "use alchemy" directive.
It lets you write normal JS and get reactive updates without manual .value plumbing.
pnpm add preact-alchemyAdd the directive as the first statement inside a function body:
function counter() {
'use alchemy'
let count = 0
count += 1
return count
}This is rewritten to:
import { signal as __alchemy_signal } from '@preact/signals'
function counter() {
let count = __alchemy_signal(0)
count.value += 1
return count.value
}When a function body starts with the string literal directive "use alchemy", the transform:
- Rewrites
letdeclarations in that function into signals. - Rewrites reads/writes of those bindings to use
.value. - Projects object literal shorthands that reference reactive bindings.
- Injects
import { signal as __alchemy_signal } from "@preact/signals";when needed.
Only let declarations are restricted: let declarations inside loops or nested functions are
not converted into signals. Reads and writes of a reactive binding remain reactive anywhere it is
in scope, including inside loops and nested functions.
function demo() {
'use alchemy'
let count = 0 // becomes a signal
for (let i = 0; i < 2; i++) {
count++ // reactive read/write
let local = 0 // NOT converted
}
function inner() {
count++ // reactive read/write
let local = 0 // NOT converted
}
}Shorthand properties referencing a reactive binding are rewritten so you don’t leak signals by accident:
return { count }- At the top level: becomes a getter so reads stay reactive.
- Inside loops/nested functions: becomes an eager value.
return {
get count() {
return count.value
},
}
// or
return { count: count.value }Destructuring let declarations are supported. Each extracted name becomes a signal:
let { a, b } = obj
// ->
let { a, b } = obj
a = __alchemy_signal(a)
b = __alchemy_signal(b)Our Vite plugin should work with Vite, Rolldown, and Rollup. You don't need Vite installed to use it.
// vite.config.ts
import { defineConfig } from 'vite'
import preactAlchemy from 'preact-alchemy/vite'
export default defineConfig({
plugins: [preactAlchemy()],
})The plugin:
- Runs on
.js/.jsx/.mjs/.cjs/.ts/.tsx/.mts/.ctsfiles, after Vite compiles TypeScript to JavaScript. - Skips files in
node_modules. - Only transforms files that include the
"use alchemy"directive.
Use the built-in esbuild plugin when you want to integrate with esbuild or tools built on top of it:
pnpm add @preact-alchemy/esbuild-pluginimport { build } from 'esbuild'
import preactAlchemy from '@preact-alchemy/esbuild-plugin'
await build({
entryPoints: ['src/index.tsx'],
bundle: true,
plugins: [preactAlchemy()],
})The plugin:
- Runs after TypeScript/JSX are compiled to JavaScript (via
onTransform). - Applies to
.js/.jsx/.mjs/.cjs/.ts/.tsx/.mts/.ctsfiles. - Skips files in
node_modulesby default. - Only transforms files that include the
"use alchemy"directive.
type PreactAlchemyEsbuildOptions = {
include?: RegExp
exclude?: RegExp
}include: RegExp to opt into files (defaults to common JS/TS extensions).exclude: RegExp to skip files (defaults to/node_modules/).
import { transform } from 'preact-alchemy'
const result = transform(code, id)
console.log(result.code)
console.log(result.map) // Source map if changes were madecode: string input source.id: optional file name for warnings and source maps.- Returns
{ code, map? }.
- JavaScript + JSX only. TypeScript/Flow syntax is not supported and is skipped with a warning. TypeScript is supported only after compilation to JavaScript (for example, via the Vite plugin).
- Only
letdeclarations are converted to signals.constandvarare left untouched. - The directive must be the first statement in the function body.
- You can keep normal JS semantics and sprinkle reactivity only where needed.
- If you already use a
__alchemy_signalvariable, the import is auto-aliased.
MIT