Action reference
Look up an Action property, binding or execution result.
Use this page to look up an option after completing the small Action guide.
The examples use the todos.list service at the end; you do not need that full service for your first Action.
Properties at a glance
| Property | Type | Default | What it does |
|---|---|---|---|
title | string or function | from the ID | Label of buttons and menu items |
description | string or function | none | Longer explanation of what the action does |
tooltip | string or function | none | Short hover hint |
icon | icon component or function | none | Shown before the title |
variant | 'default' | 'destructive' or function | 'default' | Marks a dangerous operation |
enabled | boolean or function | true | false: shown but cannot run |
visible | boolean or function | true | false: not rendered and cannot run |
input | schema | none | Validates and types the input; disabled while invalid |
execute | (context) => result | none | The operation itself |
href | string or function | none | Navigation target; rendered as a link |
dialog | component, or { component, kind?, size? } | none | Opens a dialog instead of running execute |
confirm | string, { title, description } or function | none | Asks before running |
concurrency | 'exclusive' | 'per-input' | 'latest' | exclusive | What happens when the action is already running |
requires | string[] | none | Services from other plugins this action needs |
success, failure | message, false or function | see below | Message after a run |
slots | slot names or placement objects | none | Places references to this Action in explicit UI slots |
groups | string[] | none | Groups the action is in |
order, dividerBefore | number, boolean | 0, false | Display hints among other items |
shortcut | { keys, input?, scope?, allowInInputs? } | none | Keyboard shortcut |
checked | boolean or function | none | State of a toggle |
value | any or function | none | Selected value of a choice |
metadata | any | none | Your own data for custom renderers |
internal | boolean | false | Left out of menus, lists, shortcuts and typed references |
id | string | from the file path | Explicit ID for manual composition |
Functions re-run on change. Every presentation property accepts a value or a function of { input, services, config, app }. The function runs for each binding with that binding's input, and again whenever a service store it reads changes. Two buttons of one action can therefore show different titles or availability.
Text
title, description and tooltip are three different texts. The title is the label, the description explains the action and the tooltip is a short hint on hover. Set only the ones you need.
title
The displayed text is the first match of:
titleas a translation key, for exampletitle: 'todos.addTodo';- the key
<id>.title, heretodos.add.title; titleas written.
Without title, the key <id>.title is tried and then a title made from the last part of the ID: todos.clear-done becomes "Clear done". Titles update when the language changes. A plugin ships its texts in locales/en.json under its own namespace ({ "todos": { "add": { "title": "Add todo" } } }); discovery merges them for the host's translation provider.
A function title is computed per binding: title: ({ input }) => (input ? `Add "${input}"` : 'Add todo').
Presentation functions (title, description, tooltip, variant, href, enabled, visible, checked, value) and confirm also run without input, for menus, search, group items and slot placements without an input mapping, so their input is typed as possibly undefined. They see the raw value before schema validation; only execute receives the parsed input.
description
A longer explanation, for example in a list of actions or as help text. Buttons, links and menu items show it as hover text when there is no tooltip, and custom renderers read it from the control state. Translation works like title: the value as a key, then <id>.description, then the text.
tooltip
A short hint on hover. It is not a shorter description. Bindings show the tooltip, or the description when there is no tooltip; a title prop you pass to the control wins. Translation: the value as a key, then <id>.tooltip, then the text.
Appearance
icon
Any React component that accepts className and aria-hidden, for example from your icon library:
// src/plugins/todos/actions/add.ts
import { defineAction } from '@bitakit/core';
import Plus from 'reicon-react/icons/Plus';
export default defineAction<string>({
title: 'Add todo',
icon: Plus,
execute: ({ services, input }) => services.todos.list.add(input),
});<Action> renders it before the title in an empty child and hides it from screen readers; menu items do the same. Core only stores it.
variant
'destructive' marks a dangerous operation. Menu items use the destructive style. A bound button does not change its look by itself; pass your button's own prop: <Action id="todos.clear"><Button variant="destructive" /></Action>.
Availability
Three separate states decide whether an action can run:
| Who sets it | Meaning | The control | Running it | |
|---|---|---|---|---|
enabled | you: value, function, schema, config cap | false: not available now | shown, disabled | skipped with reason disabled |
visible | you: value, function, config cap | false: not offered here | not rendered | skipped with reason hidden |
pending | the runtime | true: a run is in progress | disabled, aria-busy | skipped with reason pending |
None of them is security: the server still checks permissions.
enabled
A value or a function. A function reads the binding's input and the services, and re-runs when a store it reads changes:
// src/plugins/todos/actions/clear.ts
import { defineAction } from '@bitakit/core';
export default defineAction({
title: 'Clear all',
// Re-evaluated whenever the list's store changes: disabled while the list is empty.
enabled: ({ services }) => services.todos.list.state.get().todos.length > 0,
execute: ({ services }) => services.todos.list.clear(),
});The app can also switch an action off with actions.overrides in bitakit.config.ts; see overrides.
visible
visible: false removes the action from the UI: bindings render nothing and menus leave it out. Like enabled, it accepts a function: visible: ({ app }) => app.has('billing.invoices'). Use enabled for "not now" and visible for "not here".
pending
pending is not a property you set. The runtime sets it to true while a run that returned a promise is in progress and back to false when the run ends. During that time bindings disable themselves and another run of the same action is skipped (see concurrency). Read it with useAction when you build your own control.
input
A schema from zod, valibot or any Standard Schema library types and validates the input:
// src/plugins/todos/actions/add.ts
import { defineAction } from '@bitakit/core';
import { z } from 'zod';
export default defineAction({
title: 'Add todo',
input: z.string().trim().min(1, 'Enter a title.'),
execute: ({ services, input }) => services.todos.list.add(input), // input is a string
});With a schema, input and the result are inferred; there is nothing to repeat in a type argument. A bound control is disabled while its current input is invalid (unless you set enabled yourself), and execute receives the parsed value. Invalid input from code fails the run with the schema's message.
Running
execute
The operation itself. It receives one context object:
| Field | What it is |
|---|---|
input | The value the binding or caller passed, validated when the action has a schema |
services | Every service: services.todos.list, services.auth |
config | config.plugin and config.action scopes; see configuration |
app | The app: app.services, app.actions, app.events, app.config, app.has() |
events | Emit this plugin's events |
signal | Aborts when the registration is disposed or a newer latest run supersedes this one |
id | This action's ID |
get() | The current snapshot of this registration |
update(patch) | Changes this registration's registered values, for example update({ checked: true }) |
// src/plugins/todos/actions/add.ts
import { defineAction } from '@bitakit/core';
export default defineAction<string, number>({
title: 'Add todo',
async execute({ input, services, config }) {
const limit = config.action.get('limit', 100);
const { list } = services.todos;
if (list.state.get().todos.length >= limit) throw new Error('Todo limit reached.');
list.add(input);
return list.state.get().todos.length; // the caller receives this value
},
});What you return reaches the caller as { status: 'completed', value }. With an explicit input type, also name the result type, as in defineAction<string, number>; with a schema both are inferred. Throwing makes the run fail: the status becomes failed, the snapshot's error holds the error and the caller's promise rejects. execute may be async; pending is set only while a returned promise is open. An action without execute, href or dialog is skipped with the reason no-handler.
concurrency
What happens when the action is executed while it is already running:
| Value | Behavior |
|---|---|
'exclusive' | Default. The second run is skipped with the reason pending; every binding shows pending. |
'per-input' | Each input runs and shows pending separately, for example one row of a table at a time. |
'latest' | The previous run's signal aborts and the new run proceeds, for searches and autosave. |
concurrencyKey: (input) => string identifies inputs that are objects.
confirm
Asks before running, through the host's confirmation dialog:
// src/plugins/todos/actions/clear.ts
import { defineAction } from '@bitakit/core';
export default defineAction({
title: 'Clear all',
variant: 'destructive',
confirm: ({ services }) => ({
title: 'Remove every todo?',
description: `${services.todos.list.state.get().todos.length} todos will be deleted.`,
destructive: true,
}),
execute: ({ services }) => services.todos.list.clear(),
});A string is a title without description; a function may return false to skip the question. Declining skips the run with the reason cancelled. The action shows pending while the question is open.
requires
Lists services from other plugins that the action uses:
// src/plugins/reports/actions/export.ts
import { defineAction } from '@bitakit/core';
export default defineAction({
title: 'Export report',
// The todos.list service belongs to another plugin, so declare it.
requires: ['todos.list'],
execute: ({ services }) =>
services.todos.list.state
.get()
.todos.map((todo) => todo.title)
.join('\n'),
});At startup the app checks that each listed service exists. If one is missing, startup fails with an error that names this action and suggests close names; in development the plugin is turned off instead and a dialog explains why. A service that is not listed still fails clearly, but only when the action first reads it. Services of the action's own plugin need no entry.
success and failure
Messages after a run. The defaults: success is silent, a failure shows one message:
// src/plugins/todos/actions/add.ts
import { defineAction } from '@bitakit/core';
export default defineAction<string>({
title: 'Add todo',
execute: ({ services, input }) => services.todos.list.add(input),
success: ({ input }) => `Added "${input}"`,
failure: 'Could not add the todo.',
});Each accepts:
| Value | Meaning |
|---|---|
| omitted | The <id>.success or <id>.failure catalog key when the catalog has it; otherwise silent success, and for failures the error message in development or a generic text in production |
'Text' | The text; it is tried as a translation key first |
{ messageKey, values } | A translation key with values, for example { messageKey: 'todos.added', values: { title: input } } |
{ message, closeDialog: false } | Text that keeps a dialog open |
false | No message |
| a function | Receives { result, input, services } or { error, input, services } and returns one of the above |
Messages go to your provider's notify(message, kind) callback, for example a toast; Shell supplies one. Bindings also receive the failure as an ActionFailure in onError: its message is the failure text and its cause the original error. Messages never change the outcome of a run.
Navigation and dialogs
Navigation actions
An action with href is a link:
// src/plugins/todos/actions/open.ts
import { defineAction } from '@bitakit/core';
export default defineAction({ title: 'Todos', href: '/todos' });href must be a path starting with /, a #hash or an http(s) URL without credentials; anything else is rejected when the action is registered. <Action> renders it as a real link through the app's router, so open-in-new-tab and copy-link work; running it from code navigates. With Shell installed and mounted, add slots: ['shell.navigation'] to place it in navigation. Group membership alone does not place UI. The route itself is a plugin page (ui/pages/index.tsx) or a normal framework route; a navigation action does not create it. href may be a function of the input: href: ({ input }) => (input ? `/todos/${input}` : undefined). Search skips an Action without an href.
Dialog actions
An action with dialog opens a component in the shared dialog instead of running execute:
// src/plugins/todos/ui/dialogs/todo-details.tsx
'use client';
export function TodoDetails({ input, close }: { input: number; close(): void }) {
return (
<div>
<p>Todo {input}</p>
<button type="button" onClick={close}>
Close
</button>
</div>
);
}// src/plugins/todos/actions/details.ts
import { defineAction } from '@bitakit/core';
import { TodoDetails } from '../ui/dialogs/todo-details';
export default defineAction<number>({
title: 'Todo details',
dialog: { component: TodoDetails, size: 'wide' },
});The component receives { input, close, services } and runs its own logic, for example an ordinary action through useAction. The dialog shows the action's title and description; only one is open at a time. Bind it like any action: <Action id="todos.details" input={todo.id}><Button /></Action>. An action opens a dialog or runs execute, not both.
Keyboard
shortcut
// src/plugins/todos/actions/add.ts
import { defineAction } from '@bitakit/core';
export default defineAction<string>({
title: 'Add todo',
shortcut: { keys: 'Mod+Shift+N', input: 'New todo' },
execute: ({ services, input }) => services.todos.list.add(input),
});| Field | Meaning |
|---|---|
keys | TanStack Hotkeys syntax; Mod is Command on macOS and Control elsewhere |
input | Passed when the shortcut runs; required when the input type does not allow undefined |
scope | The shortcut works only while the provider activates this scope; default global |
allowInInputs | Also run while typing in an input, textarea, select or editable element; off by default |
Shortcuts work without a rendered button and use the same guarded execution: nothing happens while the action is hidden, disabled or running. If two actions use the same keys, the first keeps them and the conflict is reported to onError. Menu items show the keys automatically; elsewhere use <ActionShortcutLabel keys="Mod+Shift+N" /> from @bitakit/ui.
A scoped shortcut needs its scope activated on the provider: <BitaKitProvider actionOptions={{ shortcuts: { scopes: ['global', 'editor'] } }}>.
Toggles and choices
checked
The state of a toggle. Keep the state in a service; the action reads it and the control changes it:
// src/plugins/todos/actions/show-done.ts
import { defineAction } from '@bitakit/core';
export default defineAction<boolean>({
title: 'Show completed',
checked: ({ services }) => services.todos.list.state.get().showDone,
execute: ({ services, input }) => services.todos.list.setShowDone(input),
});<Action id="todos.show-done" kind="toggle">
<Switch />
</Action>With kind="toggle" the child receives checked, disabled, aria-label and onCheckedChange(value), which fits shadcn's Checkbox and Switch; onCheckedChange runs the action with the new value. A button (kind="button") exposes checked as aria-pressed instead.
value
The selected value of a choice:
// src/plugins/todos/actions/sort.ts
import { defineAction } from '@bitakit/core';
export default defineAction<'newest' | 'title'>({
title: 'Sort by',
value: ({ services }) => services.todos.list.state.get().sort,
execute: ({ services, input }) => services.todos.list.setSort(input),
});<Action id="todos.sort" kind="choice">
<RadioGroup>
<RadioGroupItem value="newest" /> Newest first
<RadioGroupItem value="title" /> Title
</RadioGroup>
</Action>With kind="choice" the child receives value, disabled and onValueChange(value), which fits shadcn's RadioGroup and Select.
Other properties
metadata
Your own data. Core stores it unchanged and never reads it; a custom renderer decides what it means. Read it from useAction(id)?.metadata.
internal
internal: true keeps a helper action out of useActions, menus, groups, shortcuts and typed references:
// src/plugins/todos/actions/import.ts
import { defineAction } from '@bitakit/core';
export default defineAction<string[]>({
internal: true, // run from code only
execute: ({ services, input }) => input.forEach((title) => services.todos.list.add(title)),
});Run it by ID through the action list: useActionList().execute('todos.import', ['Buy milk']).
Groups
Put related actions under one name with groups: ['todos.bulk']. A group only groups. Components read it and decide how to show it: an explicitly bound ActionMenu as a dropdown or search as a heading. Shell uses slots for placement. Action groups explains titles, which components use groups, and order and separators.
Forms
A form collects values and an action receives them as input. The form can sit on a page, in a dialog or inside a larger form; the action stays the same. Build the form with your form library and run an ordinary action with the validated values. This example uses react-hook-form and zod; any form library works the same way.
// src/plugins/todos/schema.ts
import { z } from 'zod';
export const todoSchema = z.object({
title: z.string().trim().min(1, 'Enter a title.'),
});
export type TodoInput = z.infer<typeof todoSchema>;// src/plugins/todos/actions/create.ts
import { defineAction } from '@bitakit/core';
import { todoSchema } from '../schema';
export default defineAction({
title: 'Create todo',
// Actions also run from code and shortcuts, so the action validates too.
input: todoSchema,
execute: ({ services, input }) => services.todos.list.add(input.title),
});// src/plugins/todos/ui/todo-form.tsx
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from '@bitakit/ui';
import { todoSchema, type TodoInput } from '../schema';
export function TodoForm({ onCreated }: { onCreated?: () => void }) {
const create = useAction('todos.create');
const form = useForm<TodoInput>({
resolver: zodResolver(todoSchema),
defaultValues: { title: '' },
});
const [error, setError] = useState<string>();
const submit = form.handleSubmit(async (values) => {
setError(undefined);
try {
const outcome = await create?.execute(values);
if (outcome?.status !== 'completed') return;
form.reset();
onCreated?.();
} catch (failure) {
// The values stay in the form, so the user can correct them and retry.
setError(failure instanceof Error ? failure.message : 'Could not create the todo.');
}
});
if (!create?.visible) return null;
const titleError = form.formState.errors.title?.message;
return (
<form onSubmit={submit} noValidate>
<label htmlFor="todo-title">Title</label>
<input id="todo-title" aria-invalid={Boolean(titleError)} {...form.register('title')} />
{titleError && <p role="alert">{titleError}</p>}
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={create.disabled} aria-busy={create.pending || undefined}>
{create.title}
</button>
</form>
);
}What you get: no popup; the validated values are the action's input; while the action runs, create.pending disables the button and a second submit is skipped; a failure rejects execute, the form shows the message and keeps the values; it resets only after a completed outcome. For a section of a larger form, do not nest <form> elements: validate the section's fields (react-hook-form's trigger) and run the section's action from a type="button".
A dialog is an optional host. Render the same form in a dialog action: defineAction({ title: 'New todo', dialog: TodoFormDialog }), where the dialog component renders <TodoForm onCreated={close} />.
Bindings
How binding works
A binding connects a control to an action by ID. It never copies the action:
- Register. At startup each action is registered once in the app's action list. Each registration has a live snapshot: its registered values plus
enabled,visible,pending,statusanderror. - Subscribe. A binding looks up the action by ID or typed reference and re-renders when the snapshot changes: after a store change,
update, or a language switch. - Resolve. The binding evaluates the function-valued properties and the schema with its own input. That is why one registration can serve every row of a table.
- Run. Activating the control calls
execute(id, input)on the action list. The executor checks again that the action exists, is visible, enabled, not running and has a handler; then interceptors andconfirmrun, then yourexecute. While it runs,pendingistrueand bindings disable themselves. - Finish. The status becomes
succeededorfailed. Success feedback goes tonotify; a failure goes to the binding'sonError.
All bindings come from @bitakit/ui and must be rendered inside your app's provider. A binding of a missing or hidden action renders nothing.
<Action>
Binds an action to any component:
// src/plugins/todos/ui/toolbar.tsx
'use client';
import { Action } from '@bitakit/ui';
import { Button } from '@bitakit/ui/components';
export function Toolbar({ draft }: { draft: string }) {
return (
<div>
{/* An empty child gets the icon and title. */}
<Action id="todos.add" input={draft}>
<Button />
</Action>
{/* Your own content and props; variant, size and className pass through. */}
<Action id="todos.clear" onError={(error) => console.warn(error)}>
<Button variant="destructive" size="sm">
Clear
</Button>
</Action>
{/* A render function for full control. */}
<Action id="todos.add" input={draft}>
{(add) => <button {...add.props()}>{add.pending ? 'Adding…' : add.title}</button>}
</Action>
</div>
);
}| Prop | Meaning |
|---|---|
id | Action ID or typed reference |
input | Passed to the function-valued properties, the schema and execute |
kind | button, link, toggle, choice or menuItem; default link for actions with an href, otherwise button |
onError | Receives failures; default is the provider's onError (console.error) |
onExecuted | Receives the outcome after a run, for example to clear a draft |
pending | Rendered inside the child while the action runs, after its content |
children | One element, or a function receiving the control |
The generated props are merged into the child: handlers are chained, class names joined and disabled combined. A button receives type="button", disabled, aria-busy, aria-pressed from checked and the tooltip (or description) as hover text. A link receives href, aria-disabled and client-side navigation for internal paths; a plain <a> child is swapped for the router's link component.
ActionButton and ActionLink are sugar on top of <Action>: <ActionButton action="todos.add" input={draft} as={Button} variant="outline">Add</ActionButton> and <ActionLink action="todos.open" />.
useAction
The control for one action, for full control or your own component:
// src/plugins/todos/ui/add-with-status.tsx
'use client';
import { ActionStatus } from '@bitakit/core';
import { useAction } from '@bitakit/ui';
export function AddWithStatus({ draft }: { draft: string }) {
const add = useAction('todos.add', draft);
if (!add?.visible) return null;
return (
<button {...add.props()}>
{add.title}
{add.status === ActionStatus.Failed && ' (failed, try again)'}
</button>
);
}useAction(action, input?) returns undefined for an unknown ID. The result has the resolved state for input (title, description, tooltip, icon, href, variant, checked, value, shortcut, enabled, visible, pending, disabled, status, error, metadata), execute(input?), which returns the outcome and rejects when the run fails, and prop getters to spread on any component:
| Getter | Props | Fits |
|---|---|---|
props() | type, disabled, aria-busy, aria-pressed, title, onClick | Buttons |
linkProps() | href, aria-disabled, tabIndex, onClick | Anchors and router links |
checkedProps() | checked, disabled, aria-label, onCheckedChange | Checkbox, Switch |
valueProps() | value, disabled, onValueChange | RadioGroup, Select |
menuItemProps() | disabled, aria-busy, title, variant, onSelect | Dropdown menu items |
Every getter accepts { onError, onExecuted }. The provider's controls option renames props for libraries with other conventions, for example { button: (state) => ({ isDisabled: state.disabled }) }.
useActionGroup and ActionMenu
import { ActionMenu, useActionGroup } from '@bitakit/ui';
<ActionMenu group="todos.bulk" label="Bulk actions" />;
function RowActions({ todo }: { todo: Todo }) {
const row = useActionGroup('todos.row', todo.id);
return row.items.map((item) => (
<button key={item.id} {...item.props()}>
{item.title}
</button>
));
}useActionGroup(group, input?) returns the group's visible actions as controls bound to input, sorted by order then ID. ActionMenu renders a group with your app's menu component. See Action groups.
ActionBoundary
Renders its children while the action is enabled, nothing while it is hidden, and otherwise a fallback: <ActionBoundary action="todos.share" fallback={<p>Sharing is not configured.</p>}>…</ActionBoundary>. It is a UI gate, not authorization.
Typed references
app.actions mirrors the action IDs: app.actions.todos.add is a typed reference to the action todos.add. Use it in a binding (<Action id={app.actions.todos.add} input="Buy milk">) or from code (await app.actions.todos.add.execute('Buy milk')). A misspelled reference is a compile error; a misspelled string ID is one too in generated apps, where strict action IDs are on. IDs with dashes use brackets: app.actions.todos['show-done']. Outside React, use runtime.actions.execute('todos.add', 'Buy milk'); in plugin tests, mountPlugin(...).execute('add', 'Buy milk') from @bitakit/core/testing.
useActions
All public action snapshots, for lists such as a command palette: Object.values(useActions()).filter((action) => action.visible). These snapshots have no input; use useAction(id, input) for input-specific state. The current low-level useActions({ internal: true }) option exposes internal registrations too; do not use it for menus, search, shortcuts or other public Action surfaces.
Actions are files
An action that belongs to a feature is a file in actions/. A truly component-local operation is an ordinary event handler. If a component-owned operation must be an action anyway (for a shortcut or a menu), register it in an effect through useActionList().register({ id, title, execute }) and call the returned unregister on cleanup; these low-level definitions have no services or config context.
Execution status and outcomes
The snapshot's status describes the last run of a registration:
| Status | Meaning |
|---|---|
ActionStatus.Idle | Not run since it was registered |
ActionStatus.Pending | Running; pending is true |
ActionStatus.Succeeded | The last run finished; error is cleared |
ActionStatus.Failed | The last run threw; error holds the error |
Running an action resolves to an outcome:
| Outcome | Meaning |
|---|---|
{ status: 'completed', value } | It ran; value is what execute returned |
{ status: 'skipped', reason } | It did not run; status and error stay unchanged |
| rejected promise | execute threw, or the input failed validation |
Skip reasons: missing (unknown ID), hidden, disabled, pending (already running), no-handler, intercepted (an interceptor refused), cancelled (the confirmation was declined) and replaced (the registration changed while starting). Bindings handle all three outcomes for you; when you call execute yourself, check the outcome and catch failures.
Identity
The ID is the action's name. Bindings, shortcuts, overrides and tests refer to the action by its ID. With discovery, the plugin folder and the file path build it:
| File | ID |
|---|---|
src/plugins/todos/actions/add.ts | todos.add |
src/plugins/todos/actions/bulk/archive.ts | todos.bulk.archive |
src/actions/reset.ts (app action) | reset |
Moving or renaming the file renames the action; bitakit doctor and the type check point at every stale ID. An ID starts with a letter or digit and may contain letters, digits, ., _, :, / and -. It is unique in the app: registering the same ID twice fails at startup with both sources. Without discovery (manual composition) the record key or id supplies the name.
Translation keys (<id>.title, <id>.success, …) and the action's config scope (configs/actions/<action>.ts) follow the same path. See type safety for typed IDs and inputs and overrides for capping actions from the app.
Example service
This fixture supports the independent examples above; it is not a starter template.
// src/plugins/todos/services/list.ts
import { defineService } from '@bitakit/core';
export type Todo = { id: number; title: string; done: boolean };
export default defineService(({ store }) => {
// Observable state: actions and pages follow it without any wiring.
const state = store<{ todos: Todo[]; showDone: boolean; sort: 'newest' | 'title' }>({
todos: [],
showDone: false,
sort: 'newest',
});
return {
state,
add(title: string) {
state.set((s) => ({ ...s, todos: [...s.todos, { id: Date.now(), title, done: false }] }));
},
remove(id: number) {
state.set((s) => ({ ...s, todos: s.todos.filter((todo) => todo.id !== id) }));
},
clear() {
state.set((s) => ({ ...s, todos: [] }));
},
setShowDone(showDone: boolean) {
state.set((s) => ({ ...s, showDone }));
},
setSort(sort: 'newest' | 'title') {
state.set((s) => ({ ...s, sort }));
},
};
});Owning a standalone ActionList
createActionList() from @bitakit/core/actions is the headless primitive for tests and
custom hosts. Normal applications already have one runtime-owned list; do not add a second
provider or register discovered Actions again.
list.register({ id, title, execute, ... }) accepts a low-level ActionDefinition and
returns get, update, onDispose and unregister. Keep the handle and unregister when
its owner closes. Low-level handlers have the Action execution context, not a plugin's
services, settings or configuration injection. Registration ownership protects replacements
from stale asynchronous completion.
list.use({ before }) installs an execution gate and returns its disposer. A false result
skips; promises are awaited and execution guards are checked again afterwards.
list.subscribeExecution(listener) observes started, completed, skipped and failed,
with the Action ID and timestamp. Release the subscription with its owner. Observer failures
do not change a completed operation's outcome. Avoid logging private input; this stream is
not a durable server audit trail. In a plugin, prefer Interceptors.
Core does not access browser globals. A standalone host must supply navigation through
list.mount(navigate) before executing an href Action. The UI ActionProvider supplies
browser navigation; framework adapters use the existing router and link component.