Skip to main content

Reducers in depth

Reducers are the one part of a app that must be pure. Everything downstream — sync, replay, undo, audit — assumes that running the same reducer against the same inputs produces the same outputs, forever.

This lesson covers the patterns that keep reducers tidy and the ones that quietly break them.

Three rules, always

  1. No I/O. No fetch, no localStorage, no console.log.
  2. No time or randomness. No Date.now(), no Math.random(), no crypto.randomUUID() — those values come from action inputs.
  3. Mutate only the provided draft. Powerhouse wraps reducers in Mutative, so you mutate state in place. Never touch module-level variables or anything outside the state argument.

If you want any of those things, they belong in one of:

  • Action inputs — the caller provides the id, timestamp, or external data.
  • Processors — derived read models that react to operations after they're appended.
  • The editor — for UI-facing side-effects.

State scopes

Real documents have more than one kind of state. Powerhouse splits the state of a document into scopes:

ScopeWhat it holdsWho can write
globalThe canonical shape of the documentAny authenticated actor
localPer-actor UI state (cursor, selection)Only the actor it belongs to

Reducers are split along the same lines. When you define an operation in Vetra Studio, you mark which scope it writes to. Codegen emits a typed action creator that passes the scope through to createAction automatically:

// generated in document-models/my-doc/gen/creators.ts
export const setCursor = (input: SetCursorInput) =>
createAction<SetCursorAction>(
"SET_CURSOR",
{ ...input },
undefined,
SetCursorInputSchema,
"local",
);

You never call createAction directly — use the generated creator (setCursor(...)) from document-models/<your-model>/gen/creators.ts.

QuizYou're writing a collaborative markdown editor. Which of these actions should live in the local scope?

Pattern: invert for undo

Because reducers are pure, undo is a pattern, not a feature you have to implement inside the reducer. For each action you support, emit a paired "inverse" action that the app can dispatch to roll it back.

function invert(op: Operation): Action | null {
switch (op.type) {
case "ADD_TODO":
return removeTodo({ id: op.input.id });
case "REMOVE_TODO":
return addTodo({ id: op.input.id, title: op.input.restoreTitle });
case "COMPLETE_TODO":
return uncompleteTodo({ id: op.input.id });
default:
return null;
}
}

Dispatching the inverse produces a new operation in the log — you keep the history, and the effect is undone. Nothing about the log is rewritten.

Testing reducers

Because reducers are pure functions, they test like pure functions. No mocking, no fixtures, no in-memory DB:

import { utils, reducer } from "my-package/document-models/todo";
import { addTodo, completeTodo } from "my-package/document-models/todo";

test("completing a todo marks it done", () => {
let doc = utils.createDocument();
doc = reducer(doc, addTodo({ id: "t-1", title: "Test" }));
const afterComplete = reducer(doc, completeTodo({ id: "t-1" }));

expect(afterComplete.state.global.items[0].done).toBe(true);
expect(doc.state.global.items[0].done).toBe(false); // original snapshot untouched
});

Two things you should always cover:

  • Determinism — the same inputs produce the same output on two separate runs.
  • Snapshot isolation — the document passed into reducer is unchanged after the call; Mutative ensures that.

If either assertion fails, your reducer isn't pure — and sync will eventually betray you.