Getting started

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={...} / @clickPass 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 classesUse the built‑in component library and theme tokens; drop down to Css(child, { class, style }) only when needed
Pick a router packageUse the built‑in $router({ … })
Pick a fetch library / TanStack QueryUse 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.

React (JSX)
function Hello({ name }) {
  return <h1>Hello, {name}!</h1>;
}
export default function App() {
  return <Hello name="Ada" />;
}
Aktion
function Hello(name) {
  return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
Vue 3 (SFC)
<script setup>
defineProps({ name: String })
</script>
<template><h1>Hello, {{ name }}!</h1></template>

<!-- App.vue -->
<Hello name="Ada" />
Aktion
function Hello(name) {
  return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
Angular
@Component({
  selector: 'hello',
  template: `<h1>Hello, {{ name }}!</h1>`
})
export class Hello {
  @Input() name!: string;
}
Aktion
function Hello(name) {
  return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
Svelte
<!-- Hello.svelte -->
<script>export let name;</script>
<h1>Hello, {name}!</h1>

<!-- App.svelte -->
<Hello name="Ada" />
Aktion
function Hello(name) {
  return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
Solid
function Hello(props) {
  return <h1>Hello, {props.name}!</h1>;
}
render(() => <Hello name="Ada" />, root);
Aktion
function Hello(name) {
  return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
Vanilla JS
function hello(name) {
  const h = document.createElement('h1');
  h.textContent = `Hello, ${name}!`;
  return h;
}
document.body.append(hello('Ada'));
Aktion
function Hello(name) {
  return Text(`Hello, ${name}!`, { variant: "large-heavy" })
}
$app(Hello("Ada"))
Live

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.

React
<Button variant="primary" disabled={loading}>Save</Button>
Aktion
Button("Save", { variant: "primary", disabled: $loading })
Vue
<Button variant="primary" :disabled="loading">Save</Button>
Aktion
Button("Save", { variant: "primary", disabled: $loading })
Angular
<button [variant]="'primary'" [disabled]="loading">Save</button>
Aktion
Button("Save", { variant: "primary", disabled: $loading })
Svelte
<Button variant="primary" disabled={loading}>Save</Button>
Aktion
Button("Save", { variant: "primary", disabled: $loading })
Solid
<Button variant="primary" disabled={loading()}>Save</Button>
Aktion
Button("Save", { variant: "primary", disabled: $loading })
Vanilla JS
const btn = document.createElement('button');
btn.className = 'btn-primary';
btn.disabled = loading;
btn.textContent = 'Save';
Aktion
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.

React
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
Aktion
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
Vue
const count = ref(0)
<button @click="count++">{{ count }}</button>
Aktion
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
Angular (signals)
count = signal(0);
// template
<button (click)="count.set(count() + 1)">{{ count() }}</button>
Aktion
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
Svelte 5
let count = $state(0);
<button onclick={() => count++}>{count}</button>
Aktion
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
Solid
const [count, setCount] = createSignal(0);
<button onClick={() => setCount(count() + 1)}>{count()}</button>
Aktion
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
Vanilla JS
let count = 0;
const btn = document.createElement('button');
btn.textContent = count;
btn.addEventListener('click', () => { count++; btn.textContent = count; });
Aktion
$count = 0
$app(Button(`${$count}`, { onClick: () => $count = $count + 1 }))
Live

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.

React
<input value={text} onChange={e => setText(e.target.value)} />
Aktion
Input("name", { value: $text, onChange: v => $text = v })
Vue
<input :value="text" @input="text = $event.target.value" />
Aktion
Input("name", { value: $text, onChange: v => $text = v })
Angular
<input [value]="text" (input)="text = $event.target.value" />
Aktion
Input("name", { value: $text, onChange: v => $text = v })
Svelte
<input bind:value={text} />
Aktion
Input("name", { value: $text })   <!-- $-vars two-way bind automatically -->
Solid
<input value={text()} onInput={e => setText(e.currentTarget.value)} />
Aktion
Input("name", { value: $text, onChange: v => $text = v })
Vanilla JS
input.addEventListener('input', e => { text = e.target.value });
Aktion
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.

React
{loading ? <Spinner /> : items.length === 0
  ? <EmptyState />
  : <List items={items} />}
Aktion
$app($loading
  ? Spinner()
  : $items.length === 0
    ? EmptyState("Nothing yet")
    : List($items))
Vue
<Spinner v-if="loading" />
<EmptyState v-else-if="items.length === 0" />
<List v-else :items="items" />
Aktion
$app($loading
  ? Spinner()
  : $items.length === 0
    ? EmptyState("Nothing yet")
    : List($items))
Angular
@if (loading) { <spinner/> }
@else if (items.length === 0) { <empty-state/> }
@else { <list [items]="items"/> }
Aktion
$app($loading
  ? Spinner()
  : $items.length === 0
    ? EmptyState("Nothing yet")
    : List($items))
Svelte
{#if loading}<Spinner />
{:else if items.length === 0}<EmptyState />
{:else}<List {items} />{/if}
Aktion
$app($loading
  ? Spinner()
  : $items.length === 0
    ? EmptyState("Nothing yet")
    : List($items))
Solid
<Show when={!loading()} fallback={<Spinner />}>
  <List items={items()} />
</Show>
Aktion
$app($loading ? Spinner() : List($items))
Vanilla JS
root.replaceChildren(loading ? spinner() : list(items));
Aktion
$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.

React
{todos.map(t => (
  <li key={t.id}>{t.title}</li>
))}
Aktion
$app(Column($todos.map(t => Text(t.title))))
Vue
<li v-for="t in todos" :key="t.id">{{ t.title }}</li>
Aktion
$app(Column($todos.map(t => Text(t.title))))
Angular
@for (t of todos; track t.id) {
  <li>{{ t.title }}</li>
}
Aktion
$app(Column($todos.map(t => Text(t.title))))
Svelte
{#each todos as t (t.id)}
  <li>{t.title}</li>
{/each}
Aktion
$app(Column($todos.map(t => Text(t.title))))
Solid
<For each={todos()}>{t => <li>{t.title}</li>}</For>
Aktion
$app(Column($todos.map(t => Text(t.title))))
Vanilla JS
ul.replaceChildren(...todos.map(t => {
  const li = document.createElement('li');
  li.textContent = t.title;
  return li;
}));
Aktion
$app(Column($todos.map(t => Text(t.title))))
Live

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.

Everyone else
<!-- Vue -->     <input v-model="name" />
<!-- Angular --> <input [(ngModel)]="name" />
<!-- Svelte -->  <input bind:value={name} />
// React        <input value={name} onChange={e => setName(e.target.value)} />
Aktion
$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.

React
useEffect(() => {
  const id = setInterval(() => setNow(Date.now()), 1000);
  return () => clearInterval(id);
}, []);
Aktion
$now = Date.now()
$effect(() => { $now = Date.now() }, ["every(1000)"])
Vue
watch(query, async (q) => {
  results.value = await fetch(`/api/search?q=${q}`).then(r => r.json())
}, { debounce: 300 })
Aktion
$effect(() => {
  $results = $http({ url: "/api/search", query: { q: $query } })
}, [$query, "debounce(300)"])
Angular
effect(() => { console.log('query:', this.query()); });
Aktion
$effect(() => { console.log("query:", $query) }, [$query])
Svelte
$effect(() => { console.log('query:', query); });
Aktion
$effect(() => { console.log("query:", $query) }, [$query])
Solid
createEffect(() => console.log('query:', query()));
Aktion
$effect(() => { console.log("query:", $query) }, [$query])
Vanilla JS
const id = setInterval(tick, 1000);
window.addEventListener('beforeunload', () => clearInterval(id));
Aktion
$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.

React + TanStack Query
const { data, isLoading, error } = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetch('/api/todos').then(r => r.json())
});
return isLoading ? <Spinner /> : <List items={data} />;
Aktion
$todos = $http({ url: "/api/todos" })
$app(Async($todos, {
  loading: Spinner(),
  error:   ErrorState("Couldn't load"),
  empty:   EmptyState("No todos yet"),
  data:    List($todos.data)
}))
Vue + VueUse
const { data, isFetching, error } = useFetch('/api/todos').json()
Aktion
$todos = $http({ url: "/api/todos" })
Angular
todos$ = this.http.get<Todo[]>('/api/todos');
// template
<ng-container *ngIf="todos$ | async as todos">…</ng-container>
Aktion
$todos = $http({ url: "/api/todos" })
$app(Async($todos, { data: List($todos.data) }))
Svelte
{#await fetch('/api/todos').then(r => r.json())}
  <Spinner />
{:then todos}
  <List items={todos} />
{/await}
Aktion
$todos = $http({ url: "/api/todos" })
$app(Async($todos, { data: List($todos.data) }))
Solid
const [todos] = createResource(() => fetch('/api/todos').then(r => r.json()));
Aktion
$todos = $http({ url: "/api/todos" })
Vanilla JS
fetch('/api/todos').then(r => r.json()).then(render);
Aktion
$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.

React Router
<Routes>
  <Route path="/" element={<Home/>} />
  <Route path="/users/:id" element={<User/>} />
  <Route path="*" element={<NotFound/>} />
</Routes>
Aktion
$app($router({
  "/":          Home(),
  "/users/:id": User({ id: params.id }),
  default:      NotFound()
}))
Vue Router
const routes = [
  { path: '/', component: Home },
  { path: '/users/:id', component: User }
]
Aktion
$app($router({
  "/":          Home(),
  "/users/:id": User({ id: params.id }),
  default:      NotFound()
}))
Angular Router
const routes: Routes = [
  { path: '', component: Home },
  { path: 'users/:id', component: User }
];
Aktion
$app($router({
  "/":          Home(),
  "/users/:id": User({ id: params.id }),
  default:      NotFound()
}))
SvelteKit
// File-based: src/routes/users/[id]/+page.svelte
Aktion
$app($router({
  "/":          Home(),
  "/users/:id": User({ id: params.id }),
  default:      NotFound()
}))
Solid Router
<Routes>
  <Route path="/" component={Home} />
  <Route path="/users/:id" component={User} />
</Routes>
Aktion
$app($router({
  "/":          Home(),
  "/users/:id": User({ id: params.id }),
  default:      NotFound()
}))
Vanilla JS
window.addEventListener('hashchange', render);
Aktion
$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 }).

Typical stack
<!-- Tailwind + CSS-in-JS + theme provider -->
<ThemeProvider theme={dark}>
  <Button className="bg-blue-600 px-4 py-2 rounded">
    Save
  </Button>
</ThemeProvider>
Aktion
$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).

Everyone else
// React
useEffect(() => { localStorage.setItem('theme', theme) }, [theme]);
const initial = localStorage.getItem('theme') ?? 'light';
Aktion
$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.

Slots / children prop
// React
function Card({ children }) { return <div className="card">{children}</div> }
<Card><h1>Hi</h1></Card>

<!-- Vue -->
<template><div class="card"><slot/></div></template>
Aktion
function MyCard(children) {
  return Box(children, { padding: "md", background: "surface", border: "default" })
}
$app(MyCard([
  CardHeader("Hi"),
  Text("Body content")
]))

One‑page cheat sheet

ConceptReact / Vue / Angular / Svelte / SolidAktion
Render rootcreateRoot().render(<App/>)$app(App())
Componentfunction / class / SFCfunction Name(args) { return … }
Local stateuseState / ref / signal$count = 0
DeriveduseMemo / computedPlain expression: full = `${$f} ${$l}`
Two-way bindv-model / [(ngModel)] / bind:valuePass a $var as value
EventonClick / @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 effectuseEffect / watch / effect$effect(() => { … }, [...deps])
HTTPTanStack Query / VueUse / HttpClient$http({ url, method, query, body })
RoutingReact Router / Vue Router / Angular Router$router({ "/": Home(), default: NotFound() })
PersistencelocalStorage / cookies-js$storage.set / .get / .session / .cookies
ThemeThemeProvider + Tailwind / styled-components$theme({ … })
Outbound event$emit / EventEmitter / dispatcher$emit("name", { detail })
ChildrenJSX children / slotsPass an array of nodes as the first positional argument
Common pitfalls when porting Forgetting the $ 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