Skip to content

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

ts
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

ts
// Call directly
addItem({ id: "1", name: "Keyboard", price: 99 });

// The returned reference is callable and typed
type AddItemFn = typeof addItem;
// (product: Product) => void

mesh.action returns a typed callable reference. You can call it directly, export it, or pass it to hooks.

Use in React

tsx
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:

ts
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:

ts
mesh.action("cart.addItem", addItemHandler);

// Throws DuplicateRegistrationError
mesh.action("cart.addItem", differentHandler);

To replace an existing action (useful in tests, HMR, or reconfiguration):

ts
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

ts
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

Released under the MIT License.