Core concepts

Modules & multi-file apps.

A single program is one flat list of statements — great for a streamed reply, cramped for a real app. Modules let you split an app across many .aktion files that import and export components, variables, functions, and reactive $state, with JavaScript-like syntax and true per-file scope. The files are linked in the browser — no build step — so the playground and any web page work the same way.

A first split

Here is a two-file app. app.aktion is the entry — it holds the $app(…) root and pulls in a component declared in Greeting.aktion. Editing either file re-links the whole program; the preview shows the linked result.

Live · multi-file
app.aktion entry
import { Greeting } from "./Greeting.aktion"

$app(Greeting("Aktion"))
Greeting.aktion
export function Greeting(name) {
  return Card([
    CardHeader(`Hello, ${name}!`, { subtitle: "Rendered from an imported file" })
  ])
}

Exporting

Prefix any top-level declaration with export to make it importable from another file. Anything not exported is private to its file. Every binding kind can be exported — components, actions, hooks, reactive $state, and plain bindings:

// components.aktion
export function Card2({ title }) {        // a component
  return Card([CardHeader(title)])
}

export function track(name) {             // an action (lowercase)
  console.log(name)
}

export function $useToggle() {            // a hook ($-prefixed)
  $on = $state(false)
  return { on: $on, toggle: () => { $on = !$on } }
}

export $count = 0                         // a reactive atom
export accent = "var(--rui-primary)"      // a plain binding

export { a, b } lists, re-exports, and export of a destructuring declaration are not supported — export each binding inline, where it is declared.

Importing

Import named bindings with import { … } from "…". Rename with as, and import reactive state by keeping its $ sigil on both sides:

import { Card2 } from "./components.aktion"
import { Card2 as PanelCard } from "./components.aktion"   // aliased
import { $count, increment } from "./store.aktion"          // a shared atom + an action
import { $count as $total } from "./store.aktion"           // $ stays across `as`

Names are matched by their bare name, so export $count lines up with import { $count }. A $state import must keep its $ across as ({ $a as $b }) — mixing { $a as b } is a syntax error. from and as are ordinary identifiers everywhere else, so existing code that uses them as variable names keeps working.

True per-file scope

Each file is its own scope. A file's non-exported top-level names are private, so two files can reuse the same name without clashing — the linker renames each file's private symbols behind the scenes. Only the names you import cross the boundary.

// Button.aktion
icon = "bolt"                              // private to this file
export function PrimaryButton({ label, onClick }) {
  return Button(label, { variant: "primary", icon: icon, onClick: onClick })
}

// Link.aktion
icon = "arrow-up-right-from-square"        // a DIFFERENT, private `icon`
export function LinkButton({ label, onClick }) {
  return Button(label, { variant: "ghost", icon: icon, onClick: onClick })
}

Built-in library components (Button, Card, Column, …) and runtime globals ($util, route, console) are resolved by the runtime, not by a module, so they are always available without importing — and never renamed.

Sharing reactive state

Export a $state atom and the files that import it read and write the same reactive cell. This is the simplest way to share state across an app — a tiny store in its own file:

Live · shared store
app.aktion entry
import { $count, increment } from "./store.aktion"

$app(Column([
  Card([
    CardHeader("Shared store"),
    Text(`Count: ${$count}`),
    Button("Increment", { variant: "primary", onClick: increment })
  ])
], { gap: "lg", align: "center", padding: "xl" }))
store.aktion
export $count = 0

export function increment() {
  $count = $count + 1
}

The entry file keeps its own names canonical: the $state atoms you declare or import into app.aktion are exactly the names that serializeState(), hydrateState(), and applyDelta() target.

Where modules resolve from

A specifier can be relative, absolute (from the project root), or a full URL. URL modules are fetched over the network when the project is linked, so you can pull a shared component straight from a CDN or gist:

import { Button } from "./Button.aktion"                     // same folder
import { Card2 } from "../components/Card2.aktion"           // parent folder
import { Nav } from "/layout/Nav.aktion"                     // from the project root
import { Hero } from "https://example.com/ui/Hero.aktion"    // remote, fetched on link

A relative import inside a URL module resolves against that URL, so a remote file can pull in its own neighbours. Bare specifiers (import { x } from "lodash") have no meaning and are reported as unresolved.

The entry file

Linking starts from one entry file — app.aktion in the playground. The entry is what defines the rendered root (a $app(…) statement, or the legacy aktion = … binding); imported files normally only declare and export. The linker walks every import from the entry, merges the whole graph into one program, and the runtime renders it — import cycles are fine (each file is merged once).

In the playground

The playground is multi-file. Use the file explorer on the left to create, rename, switch, and delete files; app.aktion is always the entry. Press to download the whole project as a .zip, or the share / download buttons to get a single linked program (every file inlined). Examples and shared links load into app.aktion. Try the “Multi-file modules” preset to start from a ready-made split.

Lazy loading & code-splitting

Modules organise your source; the Lazy(…) helper defers rendering. Reach for Lazy(…) when a part of the UI should mount only when it is needed (showing a fallback until a promise resolves), and reach for modules to keep that part — and the rest of the app — in readable, reusable files.

// In app.aktion
import { Dashboard } from "./Dashboard.aktion"

$app(Column([
  Header(),
  // Render the heavy view behind a fallback until it's ready.
  Lazy(() => Dashboard(), { fallback: Skeleton({ lines: 6 }) })
]))

Best practices

Next steps