BitaKit
API reference

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

PropertyTypeDefaultWhat it does
titlestring or functionfrom the IDLabel of buttons and menu items
descriptionstring or functionnoneLonger explanation of what the action does
tooltipstring or functionnoneShort hover hint
iconicon component or functionnoneShown before the title
variant'default' | 'destructive' or function'default'Marks a dangerous operation
enabledboolean or functiontruefalse: shown but cannot run
visibleboolean or functiontruefalse: not rendered and cannot run
inputschemanoneValidates and types the input; disabled while invalid
execute(context) => resultnoneThe operation itself
hrefstring or functionnoneNavigation target; rendered as a link
dialogcomponent, or { component, kind?, size? }noneOpens a dialog instead of running execute
confirmstring, { title, description } or functionnoneAsks before running
concurrency'exclusive' | 'per-input' | 'latest'exclusiveWhat happens when the action is already running
requiresstring[]noneServices from other plugins this action needs
success, failuremessage, false or functionsee belowMessage after a run
slotsslot names or placement objectsnonePlaces references to this Action in explicit UI slots
groupsstring[]noneGroups the action is in
order, dividerBeforenumber, boolean0, falseDisplay hints among other items
shortcut{ keys, input?, scope?, allowInInputs? }noneKeyboard shortcut
checkedboolean or functionnoneState of a toggle
valueany or functionnoneSelected value of a choice
metadataanynoneYour own data for custom renderers
internalbooleanfalseLeft out of menus, lists, shortcuts and typed references
idstringfrom the file pathExplicit 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:

  1. title as a translation key, for example title: 'todos.addTodo';
  2. the key <id>.title, here todos.add.title;
  3. title as 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 itMeaningThe controlRunning it
enabledyou: value, function, schema, config capfalse: not available nowshown, disabledskipped with reason disabled
visibleyou: value, function, config capfalse: not offered herenot renderedskipped with reason hidden
pendingthe runtimetrue: a run is in progressdisabled, aria-busyskipped 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:

FieldWhat it is
inputThe value the binding or caller passed, validated when the action has a schema
servicesEvery service: services.todos.list, services.auth
configconfig.plugin and config.action scopes; see configuration
appThe app: app.services, app.actions, app.events, app.config, app.has()
eventsEmit this plugin's events
signalAborts when the registration is disposed or a newer latest run supersedes this one
idThis 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:

ValueBehavior
'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:

ValueMeaning
omittedThe <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
falseNo message
a functionReceives { 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.

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),
});
FieldMeaning
keysTanStack Hotkeys syntax; Mod is Command on macOS and Control elsewhere
inputPassed when the shortcut runs; required when the input type does not allow undefined
scopeThe shortcut works only while the provider activates this scope; default global
allowInInputsAlso 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:

  1. 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, status and error.
  2. 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.
  3. 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.
  4. 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 and confirm run, then your execute. While it runs, pending is true and bindings disable themselves.
  5. Finish. The status becomes succeeded or failed. Success feedback goes to notify; a failure goes to the binding's onError.

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>
  );
}
PropMeaning
idAction ID or typed reference
inputPassed to the function-valued properties, the schema and execute
kindbutton, link, toggle, choice or menuItem; default link for actions with an href, otherwise button
onErrorReceives failures; default is the provider's onError (console.error)
onExecutedReceives the outcome after a run, for example to clear a draft
pendingRendered inside the child while the action runs, after its content
childrenOne 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:

GetterPropsFits
props()type, disabled, aria-busy, aria-pressed, title, onClickButtons
linkProps()href, aria-disabled, tabIndex, onClickAnchors and router links
checkedProps()checked, disabled, aria-label, onCheckedChangeCheckbox, Switch
valueProps()value, disabled, onValueChangeRadioGroup, Select
menuItemProps()disabled, aria-busy, title, variant, onSelectDropdown 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:

StatusMeaning
ActionStatus.IdleNot run since it was registered
ActionStatus.PendingRunning; pending is true
ActionStatus.SucceededThe last run finished; error is cleared
ActionStatus.FailedThe last run threw; error holds the error

Running an action resolves to an outcome:

OutcomeMeaning
{ status: 'completed', value }It ran; value is what execute returned
{ status: 'skipped', reason }It did not run; status and error stay unchanged
rejected promiseexecute 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:

FileID
src/plugins/todos/actions/add.tstodos.add
src/plugins/todos/actions/bulk/archive.tstodos.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.

On this page