FormaJS

Reactive DOM library with fine-grained signals. No virtual DOM — h() creates real elements, signals update only what changed. ~15KB gzipped.
Install
npm install @getforma/core
Or use the CDN (no build step required):
<script src="https://unpkg.com/@getforma/core/dist/formajs-runtime.global.js"></script>
Why FormaJS?
Most UI libraries make you choose: simple but limited (Alpine, htmx), or powerful but complex (React, Vue, Svelte). FormaJS gives you a single reactive core that scales from a CDN script tag to a full-stack Rust SSR pipeline.
Design principles:
- Real DOM, not virtual DOM.
h('div') returns an actual HTMLDivElement. Signals mutate it directly. No diffing pass, no reconciliation overhead for simple updates. Inspired by Solid.
- Fine-grained reactivity. Powered by alien-signals. When a signal changes, only the specific DOM text node or attribute that depends on it updates — not the whole component tree.
- Three entry points, one engine. HTML Runtime (like Alpine — zero build step),
h() hyperscript (like Preact), or JSX. All share the same signal graph. Pick the right tool for the job, upgrade without rewriting.
- CSP-safe by default. The HTML Runtime includes a hand-written expression parser.
new Function() is an opt-in fallback, not a requirement. Ship to strict CSP environments without worry.
- Islands over SPAs.
activateIslands() hydrates independent regions of server-rendered HTML. Each island is self-contained. Ship less JavaScript, keep server-rendered content instant.
What FormaJS is not: It's not a framework with opinions about routing, data fetching, or state management patterns. It's a reactive DOM library. You bring the architecture.
Three Ways to Use FormaJS
1. HTML Runtime (no build step)
Drop a script tag, write data-* attributes. Zero config, zero tooling.
<script src="https://unpkg.com/@getforma/core/dist/formajs-runtime.global.js"></script>
<div data-forma-state='{"count": 0}'>
<p data-text="{count}"></p>
<button data-on:click="{count++}">+1</button>
<button data-on:click="{count = 0}">Reset</button>
</div>
Supported Directives
data-forma-state | Declare reactive state (JSON) | data-forma-state='{"count": 0}' |
data-text | Bind text content | data-text="{count}" |
data-show | Toggle visibility (display) | data-show="{isOpen}" |
data-if | Conditional render (add/remove from DOM) | data-if="{loggedIn}" |
data-model | Two-way binding (inputs) | data-model="{email}" |
data-on:event | Event handler | data-on:click="{count++}" |
data-class:name | Conditional CSS class | data-class:active="{isActive}" |
data-bind:attr | Dynamic attribute | data-bind:href="{url}" |
data-list | List rendering with keyed reconciliation | data-list="{items}" |
data-computed | Computed value | data-computed="doubled = count * 2" |
data-persist | Persist state to localStorage | data-persist="{count}" |
data-fetch | Fetch data from URL | data-fetch="GET /api/items → items" |
data-transition:* | Enter/leave CSS transitions | data-transition:enter="fade-in" |
CSP-safe expression parser — no eval() or new Function() by default. For strict CSP environments, use the hardened build:
<script src="https://unpkg.com/@getforma/core/dist/formajs-runtime-hardened.global.js"></script>
2. Hyperscript — h()
npm install @getforma/core
import { createSignal, h, mount } from '@getforma/core';
const [count, setCount] = createSignal(0);
mount(() =>
h('button', { onClick: () => setCount(count() + 1) },
() => `Clicked ${count()} times`
),
'#app'
);
3. JSX
Same h() function, JSX syntax. Configure your bundler:
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}
}
import { createSignal, h, Fragment, mount } from '@getforma/core';
const [count, setCount] = createSignal(0);
function Counter() {
return (
<>
<p>{() => `Count: ${count()}`}</p>
<button onClick={() => setCount(count() + 1)}>+1</button>
</>
);
}
mount(() => <Counter />, '#app');
If you use @getforma/build, JSX is preconfigured — just write .tsx files.
Core API
Signals
import { createSignal, createEffect, createComputed, batch } from '@getforma/core';
const [count, setCount] = createSignal(0);
const doubled = createComputed(() => count() * 2);
createEffect(() => console.log('count:', count()));
batch(() => {
setCount(1);
setCount(2);
});
Conditional Rendering
import { createSignal, createShow, createSwitch, h } from '@getforma/core';
const [loggedIn, setLoggedIn] = createSignal(false);
createShow(loggedIn,
() => h('p', null, 'Welcome back'),
() => h('p', null, 'Please sign in'),
);
const [view, setView] = createSignal('home');
createSwitch(view, [
{ match: 'home', render: () => h('div', null, 'Home') },
{ match: 'settings', render: () => h('div', null, 'Settings') },
], () => h('div', null, '404 Not Found'));
List Rendering
import { createSignal, createList, h } from '@getforma/core';
const [items, setItems] = createSignal([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
createList(
items,
(item) => item.id,
(item) => h('li', null, item.name),
);
Store (deep reactivity)
import { createStore } from '@getforma/core';
const [state, setState] = createStore({
user: { name: 'Alice', prefs: { theme: 'dark' } },
items: [1, 2, 3],
});
state.user.name;
setState('user', 'name', 'Bob');
setState('items', items => [...items, 4]);
Components
import { defineComponent, onMount, onUnmount, h } from '@getforma/core';
const Timer = defineComponent(() => {
const [seconds, setSeconds] = createSignal(0);
onMount(() => {
const id = setInterval(() => setSeconds(s => s + 1), 1000);
return () => clearInterval(id);
});
return h('span', null, () => `${seconds()}s`);
});
document.body.appendChild(Timer());
Context (Dependency Injection)
import { createContext, provide, inject } from '@getforma/core';
const ThemeCtx = createContext('light');
provide(ThemeCtx, 'dark');
const theme = inject(ThemeCtx);
Islands Architecture
For server-rendered HTML, activate independent interactive regions:
import { activateIslands, createSignal, h } from '@getforma/core';
activateIslands({
Counter: (el, props) => {
const [count, setCount] = createSignal(props.initial ?? 0);
},
});
<div data-forma-island="Counter" data-forma-props='{"initial": 5}'>
<span>5</span>
<button>+1</button>
</div>
Subpath Exports
@getforma/core | Signals, h(), mount(), lists, stores, components |
@getforma/core/runtime | HTML Runtime — initRuntime(), mount(), unmount() |
@getforma/core/runtime-hardened | Runtime with new Function() locked off (strict CSP) |
@getforma/core/ssr | Server-side rendering — renderToString(), renderToStream() |
@getforma/core/tc39 | TC39-compatible Signal.State and Signal.Computed classes |
Examples
See the examples/ directory:
- counter — minimal
h() counter
- counter-jsx — same counter with JSX syntax
- todo — todo list with
createList and keyed reconciliation
- data-table — sortable table with
createList
Ecosystem
License
MIT