Migration to Aktion.
Already shipping with React, Vue, Angular, Svelte, Solid, or plain HTML?
You can keep almost all of your mental model. Aktion is JavaScript with
three additions: reactive $variables, $app(…)
as the render entry point, and components called with named arguments.
Pick your framework below for a concept‑by‑concept walkthrough.
The mental model
Aktion is a thin DSL on top of JavaScript. A program produces a tree of
UI by calling $app(…). Reactive
state is any identifier prefixed with $; whenever it changes
the runtime re‑renders the smallest part of the tree that depended
on it. Components are plain JavaScript functions that return a
node. Side effects live in $effect(() => { … }, [...deps]),
and the network is a single primitive: $http({ url, method, … }).
| You used to… | In Aktion… |
|---|---|
Mount a root with createRoot(...).render(<App />) | Write $app(App()) |
Declare a reactive value (useState / ref / signal) | Just write $count = 0 |
Wire an event with onClick={...} / @click | Pass a named arg: { onClick: () => … } |
Render a list with .map(…) | Same: $items.map(x => …) |
Conditionally render with {cond && …} | if (cond) { … } else { … } — expressions can be assigned |
| Reach for a CSS framework / utility classes | Use the built‑in component library and theme tokens; drop down to Css(child, { class, style }) only when needed |
| Pick a router package | Use the built‑in $router({ … }) |
| Pick a fetch library / TanStack Query | Use the built‑in $http({ … }) resource |
Components
Components in Aktion are functions that return a
node. The first-letter case of the function name is not significant
— UserCard and userCard are both valid.
Components are called with positional arguments followed by an optional
named‑arguments object — the same convention used everywhere
in the standard library.
function Hello({ name }) {
return <h1>Hello, {name}!</h1>;
}
export default function App() {
return <Hello name="Ada" />;
}
function Hello(name) {
return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
<script setup>
defineProps({ name: String })
</script>
<template><h1>Hello, {{ name }}!</h1></template>
<!-- App.vue -->
<Hello name="Ada" />
function Hello(name) {
return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
@Component({
selector: 'hello',
template: `<h1>Hello, {{ name }}!</h1>`
})
export class Hello {
@Input() name!: string;
}
function Hello(name) {
return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
<!-- Hello.svelte -->
<script>export let name;</script>
<h1>Hello, {name}!</h1>
<!-- App.svelte -->
<Hello name="Ada" />
function Hello(name) {
return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
function Hello(props) {
return <h1>Hello, {props.name}!</h1>;
}
render(() => <Hello name="Ada" />, root);
function Hello(name) {
return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
function hello(name) {
const h = document.createElement('h1');
h.textContent = `Hello, ${name}!`;
return h;
}
document.body.append(hello('Ada'));
function Hello(name) {
return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
Props & named arguments
Aktion components canonically take one positional argument (when it has
an obvious identity like a label or items list) followed by a single
{ options } object — the same pattern as
Array.prototype.sort(compareFn) or
fetch(url, options). All-positional calls (arguments in
signature order) and a single all-named { } object are
also accepted. There is no JSX, no template DSL, and no
attribute‑vs‑prop dichotomy.
<Button variant="primary" disabled={loading}>Save</Button>
Button("Save", { variant: "primary", disabled: $loading })
<Button variant="primary" :disabled="loading">Save</Button>
Button("Save", { variant: "primary", disabled: $loading })
<button [variant]="'primary'" [disabled]="loading">Save</button>
Button("Save", { variant: "primary", disabled: $loading })
<Button variant="primary" disabled={loading}>Save</Button>
Button("Save", { variant: "primary", disabled: $loading })
<Button variant="primary" disabled={loading()}>Save</Button>
Button("Save", { variant: "primary", disabled: $loading })
const btn = document.createElement('button');
btn.className = 'btn-primary';
btn.disabled = loading;
btn.textContent = 'Save';
Button("Save", { variant: "primary", disabled: $loading })
State
Any identifier that starts with $ is reactive. Write to it
with a plain assignment; the runtime tracks every read inside the render
tree and re‑evaluates exactly the dependent expressions. There is
no setState, no ref().value, no Zone.js.
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
const count = ref(0)
<button @click="count++">{{ count }}</button>
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
count = signal(0);
// template
<button (click)="count.set(count() + 1)">{{ count() }}</button>
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
let count = $state(0);
<button onclick={() => count++}>{count}</button>
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
const [count, setCount] = createSignal(0);
<button onClick={() => setCount(count() + 1)}>{count()}</button>
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
let count = 0;
const btn = document.createElement('button');
btn.textContent = count;
btn.addEventListener('click', () => { count++; btn.textContent = count; });
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
Derived values are just expressions — assign them to a non‑reactive
local. They re‑evaluate whenever any $variable they
read changes, no useMemo or computed() required.
$first = "Ada"
$last = "Lovelace"
fullName = `${$first} ${$last}` // recomputes when either changes
$app(Text(fullName))
Event handlers
Every interactive component takes a callback as a named argument
(onClick, onChange, onSubmit, …).
Inline arrow functions are the idiomatic choice. The
OnClick / OnMouse / OnKeyboard
wrappers attach handlers to any component that doesn’t
already expose one.
<input value={text} onChange={e => setText(e.target.value)} />
Input("name", { value: $text, onChange: v => $text = v })
<input :value="text" @input="text = $event.target.value" />
Input("name", { value: $text, onChange: v => $text = v })
<input [value]="text" (input)="text = $event.target.value" />
Input("name", { value: $text, onChange: v => $text = v })
<input bind:value={text} />
Input("name", { value: $text }) <!-- $-vars two-way bind automatically -->
<input value={text()} onInput={e => setText(e.currentTarget.value)} />
Input("name", { value: $text, onChange: v => $text = v })
input.addEventListener('input', e => { text = e.target.value });
Input("name", { value: $text, onChange: v => $text = v })
Conditional rendering
Aktion is plain JavaScript. Use the ternary operator cond ? a : b
to pick between branches inline, and chain ternaries for three‑way
switches. Statement if…else still works in function
bodies for guard clauses and side effects — it just isn’t an
expression you can assign.
{loading ? <Spinner /> : items.length === 0
? <EmptyState />
: <List items={items} />}
$app($loading
? Spinner()
: $items.length === 0
? EmptyState("Nothing yet")
: List($items))
<Spinner v-if="loading" />
<EmptyState v-else-if="items.length === 0" />
<List v-else :items="items" />
$app($loading
? Spinner()
: $items.length === 0
? EmptyState("Nothing yet")
: List($items))
@if (loading) { <spinner/> }
@else if (items.length === 0) { <empty-state/> }
@else { <list [items]="items"/> }
$app($loading
? Spinner()
: $items.length === 0
? EmptyState("Nothing yet")
: List($items))
{#if loading}<Spinner />
{:else if items.length === 0}<EmptyState />
{:else}<List {items} />{/if}
$app($loading
? Spinner()
: $items.length === 0
? EmptyState("Nothing yet")
: List($items))
<Show when={!loading()} fallback={<Spinner />}>
<List items={items()} />
</Show>
$app($loading ? Spinner() : List($items))
root.replaceChildren(loading ? spinner() : list(items));
$app($loading ? Spinner() : List($items))
Lists
Aktion uses plain JavaScript: render a collection with
arr.map(fn). Statement for / while
loops work too — for accumulating side effects — but they
are not expressions, so you can’t write
x = for (…) { … }. Stick with
.map when you want the result.
{todos.map(t => (
<li key={t.id}>{t.title}</li>
))}
$app(Column($todos.map(t => Text(t.title))))
<li v-for="t in todos" :key="t.id">{{ t.title }}</li>
$app(Column($todos.map(t => Text(t.title))))
@for (t of todos; track t.id) {
<li>{{ t.title }}</li>
}
$app(Column($todos.map(t => Text(t.title))))
{#each todos as t (t.id)}
<li>{t.title}</li>
{/each}
$app(Column($todos.map(t => Text(t.title))))
<For each={todos()}>{t => <li>{t.title}</li>}</For>
$app(Column($todos.map(t => Text(t.title))))
ul.replaceChildren(...todos.map(t => {
const li = document.createElement('li');
li.textContent = t.title;
return li;
}));
$app(Column($todos.map(t => Text(t.title))))
Two-way binding
Pass a $variable as the value of any form
component — the runtime treats it as a two‑way bind. No
directive, no v-model, no [(ngModel)], no
explicit bind: prefix.
<!-- Vue --> <input v-model="name" />
<!-- Angular --> <input [(ngModel)]="name" />
<!-- Svelte --> <input bind:value={name} />
// React <input value={name} onChange={e => setName(e.target.value)} />
$name = ""
$app(Input("name", { value: $name, placeholder: "Your name" }))
For a whole form with validation — the job of Formik / React Hook Form /
VeeValidate — reach for $form: managed values, per‑field
$util.rules validators, touched‑tracking, and a
handleSubmit() that only fires when valid. See
Global state → Forms.
signup = $form({
values: { email: "" },
rules: { email: [$util.rules.required(), $util.rules.email()] },
onSubmit: (v) => $save.mutate({ body: v })
})
Side effects
$effect(() => { … }, [...deps]) runs after render whenever
any dependency changes. Dependencies are $variables or
lifecycle / scheduling strings: "mount", "unmount",
"every(N)", "debounce(N)", "throttle(N)".
Effects also work inside a component — in which case they are scoped
to that component’s lifetime.
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
$now = Date.now()
$effect(() => { $now = Date.now() }, ["every(1000)"])
watch(query, async (q) => {
results.value = await fetch(`/api/search?q=${q}`).then(r => r.json())
}, { debounce: 300 })
$effect(() => {
$results = $http({ url: "/api/search", query: { q: $query } })
}, [$query, "debounce(300)"])
effect(() => { console.log('query:', this.query()); });
$effect(() => { console.log("query:", $query) }, [$query])
$effect(() => { console.log('query:', query); });
$effect(() => { console.log("query:", $query) }, [$query])
createEffect(() => console.log('query:', query()));
$effect(() => { console.log("query:", $query) }, [$query])
const id = setInterval(tick, 1000);
window.addEventListener('beforeunload', () => clearInterval(id));
$effect(() => { tick() }, ["every(1000)"])
HTTP & data fetching
$http({ url, method, query, body, headers, ... }) returns a
reactive resource with .data, .error,
.loading, .status, .headers,
.lastUpdated, .refetch(), .cancel(),
and an assignable .onDone callback. Pair it with
Async(resource, { loading, error, empty, data }) for clean
rendering.
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(r => r.json())
});
return isLoading ? <Spinner /> : <List items={data} />;
$todos = $http({ url: "/api/todos" })
$app(Async($todos, {
loading: Spinner(),
error: ErrorState("Couldn't load"),
empty: EmptyState("No todos yet"),
data: List($todos.data)
}))
const { data, isFetching, error } = useFetch('/api/todos').json()
$todos = $http({ url: "/api/todos" })
todos$ = this.http.get<Todo[]>('/api/todos');
// template
<ng-container *ngIf="todos$ | async as todos">…</ng-container>
$todos = $http({ url: "/api/todos" })
$app(Async($todos, { data: List($todos.data) }))
{#await fetch('/api/todos').then(r => r.json())}
<Spinner />
{:then todos}
<List items={todos} />
{/await}
$todos = $http({ url: "/api/todos" })
$app(Async($todos, { data: List($todos.data) }))
const [todos] = createResource(() => fetch('/api/todos').then(r => r.json()));
$todos = $http({ url: "/api/todos" })
fetch('/api/todos').then(r => r.json()).then(render);
$todos = $http({ url: "/api/todos" })
Mutations use the same primitive — just pass method and
body. Re‑fetching the list after success is a single
line:
function toggle(todo) {
$patch = $http({
url: `/api/todos/${todo.id}`,
method: "PATCH",
body: { done: !todo.done }
})
$patch.onDone = () => $todos.refetch()
}
Routing
$router({ … }) returns the matched view for the current URL
(hash router by default). Inside any route, params is the
bound parameter object. NavLink(label, { to }) is a
router‑aware anchor; route.navigate(path) changes the
URL programmatically.
<Routes>
<Route path="/" element={<Home/>} />
<Route path="/users/:id" element={<User/>} />
<Route path="*" element={<NotFound/>} />
</Routes>
$app($router({
"/": Home(),
"/users/:id": User({ id: params.id }),
default: NotFound()
}))
const routes = [
{ path: '/', component: Home },
{ path: '/users/:id', component: User }
]
$app($router({
"/": Home(),
"/users/:id": User({ id: params.id }),
default: NotFound()
}))
const routes: Routes = [
{ path: '', component: Home },
{ path: 'users/:id', component: User }
];
$app($router({
"/": Home(),
"/users/:id": User({ id: params.id }),
default: NotFound()
}))
// File-based: src/routes/users/[id]/+page.svelte
$app($router({
"/": Home(),
"/users/:id": User({ id: params.id }),
default: NotFound()
}))
<Routes>
<Route path="/" component={Home} />
<Route path="/users/:id" component={User} />
</Routes>
$app($router({
"/": Home(),
"/users/:id": User({ id: params.id }),
default: NotFound()
}))
window.addEventListener('hashchange', render);
$app($router({
"/": Home(),
default: NotFound()
}))
The router scales up to the patterns you reach for libraries to provide:
nested layouts ("/app": { layout, routes } —
a shared shell with an outlet), guards
($util.onNavigate(({ to, from }) => …) to block/redirect),
query‑param state ($util.url.setQuery(…)),
lazy routes (Lazy(() => import(…))), and
scroll restoration (the
scroll-restoration attribute). See the
Routing guide.
Styling & theme
Skip CSS frameworks entirely — every component already speaks the
active theme’s tokens. Switch themes with
$theme({ … }), override single tokens, or
attach raw class / style to any component with
Css(child, { class, style }).
<!-- Tailwind + CSS-in-JS + theme provider -->
<ThemeProvider theme={dark}>
<Button className="bg-blue-600 px-4 py-2 rounded">
Save
</Button>
</ThemeProvider>
$theme({ colors: { primary: "#2563eb" } })
$app(Button("Save", { variant: "primary" }))
// Escape hatch when you really need raw CSS:
Css(Button("Save"), { class: "my-button", style: "padding: 12px;" })
Persistence & storage
Reach for the built‑in $storage namespace instead of
touching localStorage / sessionStorage / cookies
by hand. One set / get / remove / clear
API spans all three backends, non‑string values round‑trip through
JSON automatically, and every call is safe in private‑mode / SSR
(missing storage returns null rather than throwing).
// React
useEffect(() => { localStorage.setItem('theme', theme) }, [theme]);
const initial = localStorage.getItem('theme') ?? 'light';
$storage.set("theme", $pref) // localStorage by default
$pref = $storage.get("theme") ?? "light"
$storage.session.set("draft", $draft) // sessionStorage
$storage.cookies.set("uid", id, { expires: 7 })
For whole‑store persistence, skip the manual round‑trip entirely:
add persist to a $store({…}) and its data
hydrates on mount and writes back on every change (the equivalent of a
Redux‑persist setup, built in). It also offers undo/redo via
history: true — see Global
state → Persistence.
prefs = $store({ theme: "system", persist: "prefs", history: true })
// survives reload · prefs.undo() / prefs.redo()
Outbound events (host integration)
Need to tell the surrounding page something? $emit("name", { … })
dispatches a CustomEvent on the <aktion-app>
host element — the same way you would $emit in Vue or
call a Svelte createEventDispatcher().
// in any action / effect
$emit("checkout", { items: $cart, total: $total })
<!-- in the host page -->
<script>
document.querySelector('aktion-app')
.addEventListener('checkout', (e) => console.log(e.detail));
</script>
Children & composition
There are no slots, no children prop, and no transclusion.
A container component just takes its children as an array
positional argument — the same way Stack,
Card, and Grid work everywhere else.
// React
function Card({ children }) { return <div className="card">{children}</div> }
<Card><h1>Hi</h1></Card>
<!-- Vue -->
<template><div class="card"><slot/></div></template>
function MyCard(children) {
return Box(children, { padding: "md", background: "surface", border: "default" })
}
$app(MyCard([
CardHeader("Hi"),
Text("Body content")
]))
One‑page cheat sheet
| Concept | React / Vue / Angular / Svelte / Solid | Aktion |
|---|---|---|
| Render root | createRoot().render(<App/>) | $app(App()) |
| Component | function / class / SFC | function Name(args) { return … } |
| Local state | useState / ref / signal | $count = 0 |
| Derived | useMemo / computed | Plain expression: full = `${$f} ${$l}` |
| Two-way bind | v-model / [(ngModel)] / bind:value | Pass a $var as value |
| Event | onClick / @click / (click) | { onClick: fn } (named arg) |
| If | ?: / v-if / @if / {#if} / <Show> | Ternary: cond ? a : b (statement if works in function bodies) |
| List | .map / v-for / @for / {#each} / <For> | $items.map(x => …) |
| Side effect | useEffect / watch / effect | $effect(() => { … }, [...deps]) |
| HTTP | TanStack Query / VueUse / HttpClient | $http({ url, method, query, body }) |
| Routing | React Router / Vue Router / Angular Router | $router({ "/": Home(), default: NotFound() }) |
| Persistence | localStorage / cookies-js | $storage.set / .get / .session / .cookies |
| Theme | ThemeProvider + Tailwind / styled-components | $theme({ … }) |
| Outbound event | $emit / EventEmitter / dispatcher | $emit("name", { detail }) |
| Children | JSX children / slots | Pass an array of nodes as the first positional argument |
$ on state names — without it the value
is a plain constant and won’t trigger re‑render. Calling a
component without parentheses ($app(App)) — Aktion
won’t call it for you, write $app(App()). Using
positional arguments past the first one — everything after the
leading positional goes into a single { options } object.
Next steps
Installation
Add Aktion to any existing project via CDN or npm i aktion-runtime.
Language
The full surface: expressions, control flow, effects, and component calls.
Read the spec → Try itPlayground
Paste any snippet above and tweak it live with autocomplete and inspect mode.
Open the playground → AdvancedUI Providers
Keep Aktion's DSL and swap output to Material UI, Bootstrap, or ShadCN through provider adapters.
Explore provider adapters →