Actions
Actions are named synchronous state changes. They are the primary way to modify state in StateMesh. Each action has a name (for DevTools, guards, and testing) and a handler that receives a draft copy of the state.
Define an Action
const addItem = mesh.action(
"cart.addItem",
(state, product: Product) => {
const existing = state.cart.items.find((item) => item.id === product.id);
if (existing) {
existing.quantity += 1;
} else {
state.cart.items.push({ ...product, quantity: 1 });
}
}
);The handler receives a draft — a mutable proxy of the state. You can write imperative mutation code directly. The original state is never touched.
Call an Action
// Call directly
addItem({ id: "1", name: "Keyboard", price: 99 });
// The returned reference is callable and typed
type AddItemFn = typeof addItem;
// (product: Product) => voidmesh.action returns a typed callable reference. You can call it directly, export it, or pass it to hooks.
Use in React
import { useMeshAction } from "statemesh-core";
function AddToCart({ product }: { product: Product }) {
const addItem = useMeshAction(mesh.action("cart.addItem", addItemHandler));
return (
<button onClick={() => addItem(product)}>
Add to Cart
</button>
);
}useMeshAction returns a stable function reference that won't change between renders.
Action Errors
If an action handler throws, the error is wrapped in an ActionError:
import { ActionError } from "statemesh-core";
try {
removeItem("nonexistent-id");
} catch (error) {
if (error instanceof ActionError) {
console.log(error.metadata.action); // "cart.removeItem"
console.log(error.cause); // Original error
}
}Registration and Replacement
Named actions are guarded by default. Registering the same name twice throws DuplicateRegistrationError:
mesh.action("cart.addItem", addItemHandler);
// Throws DuplicateRegistrationError
mesh.action("cart.addItem", differentHandler);To replace an existing action (useful in tests, HMR, or reconfiguration):
mesh.action("cart.addItem", mockedHandler, { replace: true });During Vite browser HMR, duplicate registrations are automatically treated as replacements. Production builds still reject accidental duplicates.
Example: Todo App
const addTodo = mesh.action("todos.add", (state, text: string) => {
state.todos.push({
id: crypto.randomUUID(),
text,
completed: false
});
});
const toggleTodo = mesh.action("todos.toggle", (state, id: string) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
});
const removeTodo = mesh.action("todos.remove", (state, id: string) => {
state.todos = state.todos.filter((t) => t.id !== id);
});
const clearCompleted = mesh.action("todos.clearCompleted", (state) => {
state.todos = state.todos.filter((t) => !t.completed);
});Important Notes
TIP
Actions are synchronous. For async operations (API calls, file I/O), use transactions instead.
TIP
Action names appear in DevTools, guard rules, and event filters. Use descriptive dot-separated names like "cart.addItem" or "user.updateProfile".
WARNING
If the action handler produces the same state as the current snapshot (checked via shallowEqual), StateMesh skips the clone-and-commit cycle entirely. This is an automatic performance optimization.
Next Steps
- Selectors & Computed — Derived state with caching
- Transactions — Async operations with optimistic UI
- Guards — Block actions by name pattern
