Skip to main content

Actions and reducers

You've heard the words — action, reducer, operation. This lesson makes the contract precise, so you can reason clearly about what's allowed inside each one.

Actions are just intent

An action is plain data. Nothing more.

{
type: "ADD_TODO",
input: { id: "t-1", title: "Buy milk" }
}

No timestamp. No hash. No index. An action describes what you want to happen — it's a request, not a fact. The action alone changes nothing.

That's intentional. Separating intent from result means you can validate, replay, or reject a change before it ever touches state.

A reducer is a pure function: (state, action) => nextState. It's the single place where state is allowed to change, and it has strict rules:

  • Same inputs must always produce the same output.
  • No network calls, no randomness, no wall-clock time.
  • Synchronous only — no async, no promises.
// Valid reducer — pure transformation
addTodoOperation(state, action) {
state.todos.push({
id: action.input.id,
title: action.input.title,
done: false,
});
}

Why so strict? Because peers reconstruct state by replaying the operation log. If a reducer reads Date.now() or calls Math.random(), two machines replaying the same log end up with different state. The whole system breaks.

Non-deterministic values belong in the action input — generated before dispatch, outside the reducer:

// In the editor — generate the ID before dispatching
dispatch(
addTodo({
id: generateId(),
createdAt: new Date().toISOString(),
title: input.title,
}),
);
QuizWhich of these is a valid reducer?

Operations are what gets recorded

Once the reducer runs without throwing, the Reactor stamps the action with metadata and appends it to the document log. That record is an operation:

{
index: 42,
timestamp: "2026-04-15T10:00:00.000Z",
hash: "sha256-...",
action: { type: "ADD_TODO", input: { id: "t-1", title: "Buy milk" } },
error: null,
}

The operation is immutable history. The action was intent; the operation is fact.

If the reducer throws, the operation is still appended — but with error set to the message and state left unchanged. Failure is also part of the record.

Intent vs. result

The action/reducer/operation triangle gives you a clean separation:

Carries
ActionIntent — a name and inputs, nothing else
ReducerLogic — the only legal way state can change
OperationResult — the action plus proof it ran (index, hash, timestamp)

A caller can't sneak state changes past the reducer. A reducer can't introduce uncertainty. An operation can't be revised after the fact. Each piece does exactly one job.