By Juan Iturbe

Operations, Not Replacements: CQRS Reducers for Agent State

When your agent's state gets complex, full replacements break. Learn how to use incremental operations with CQRS reducers in LangGraph.

AIArchitectureLangGraphTypeScript

Your agent has state. A lot of state. And if you’re using LangGraph, every node returns a piece of it and a reducer decides how to merge it in.

The tutorials teach you this:

return { myField: newValue };

And it works. For demos. For strings. For numbers.

But one day your agent handles a nested object with columns and cards, derived counts, structures that branch three levels deep and replacing the whole thing on every step becomes a real problem.

The problem

You have a Kanban board. Something like:

interface Board {
  columns: Column[];
}

interface Column {
  columnId: string;
  title: string;
  cardCount: number; // derived - always cards.length
  cards: Card[];
}

interface Card {
  cardId: string;
  title: string;
  priority: number;
}

A user says: “Move T-42 from Backlog to In Progress.”

With full replacement, your tool has to:

  1. Read the entire board.
  2. Find Backlog, find T-42.
  3. Pull T-42 out of Backlog.
  4. Insert T-42 into In Progress.
  5. Recalculate cardCount on both columns.
  6. Return the full modified board.

If another tool runs in parallel and also returns a full board, one overwrites the other. If the LLM hallucinates an invalid shape - missing fields, wrong types your state corrupts silently and nobody notices until something explodes downstream.

This isn’t theoretical. It happens in production.

Operations, not replacements

Instead of replacing, emit an operation. The operation says what changed, not how everything looks now.

// Replacement: the tool rebuilds the entire board
return { board: fullBoard };

// Operation: the tool says what to move
return { board: { type: "move", cardId: "T-42", fromColumn: "backlog", toColumn: "in_progress" } };

The reducer decides how to apply that change to the current state.

The idea comes from CQRS - Command Query Responsibility Segregation. The “Command” is the operation. The “Query” is the reducer reading current state to apply it. You don’t need to implement full CQRS. You just need the part that matters: small, explicit commands instead of global replacements.

Define your operations

interface BoardMoveOp {
  type: "move";
  cardId?: string;       // omit = move entire column
  fromColumn: string;
  toColumn: string;
}

interface BoardRemoveOp {
  type: "remove";
  cardId?: string;       // omit = clear entire column
  fromColumn: string;
}

type BoardOp = BoardMoveOp | BoardRemoveOp;

Two operations. Clear. Small. Each one says exactly what it wants to do.

And they combine with full replacement to cover both cases:

type BoardUpdate = Board | BoardOp;

buildBoard returns a full Board - it’s creating from scratch. moveCard returns a BoardMoveOp - it’s modifying something that already exists. Both flow through the same reducer.

The reducer

The reducer is where everything connects. It receives current state + update, decides if it’s an operation or a replacement, and acts:

function boardReducer(
  current: Board | null,
  update: BoardUpdate,
): Board | null {
  // Incremental operation
  if (isBoardOp(update)) {
    if (!current) return current;

    const cloned = structuredClone(current);

    try {
      if (update.type === "move") applyMoveOp(cloned, update);
      else if (update.type === "remove") applyRemoveOp(cloned, update);
    } catch {
      // Invalid operation: log, preserve state, stay alive
      console.warn("Invalid operation, state preserved");
      return current;
    }

    return cloned;
  }

  // Full replacement: validate before accepting
  if (!isValidBoard(update)) {
    console.warn("Invalid board, state preserved");
    return current;
  }

  return update;
}

Things happening here:

Deep clone before mutating. Never mutate current state directly. structuredClone, mutate the copy, return the copy. If something fails, the original stays intact. No exceptions.

Validation at the boundary. Operations and replacements get validated before touching state. If the LLM hallucinates { type: "teleport", from: "mars" }, it gets rejected. State stays clean.

Reject and preserve. Invalid operations don’t break the flow. They get logged, rejected, and state stays as it was. The agent can retry. This is only possible because the reducer has full control over what gets in and what doesn’t.

How operations get applied

function applyMoveOp(board: Board, op: BoardMoveOp): void {
  const fromCol = board.columns.find(c => c.columnId === op.fromColumn);
  const toCol = board.columns.find(c => c.columnId === op.toColumn);

  if (!fromCol || !toCol) {
    throw new Error(`Column not found`);
  }

  if (op.cardId) {
    const idx = fromCol.cards.findIndex(c => c.cardId === op.cardId);
    if (idx === -1) throw new Error(`Card ${op.cardId} not found`);
    const [card] = fromCol.cards.splice(idx, 1);
    toCol.cards.push(card);
  } else {
    // Move entire column (reorder positions)
    const idx = board.columns.findIndex(c => c.columnId === op.fromColumn);
    // ... reordering logic
  }

  recalcColumn(fromCol);
  recalcColumn(toCol);
}

function applyRemoveOp(board: Board, op: BoardRemoveOp): void {
  const fromCol = board.columns.find(c => c.columnId === op.fromColumn);
  if (!fromCol) throw new Error(`Column not found`);

  if (op.cardId) {
    const idx = fromCol.cards.findIndex(c => c.cardId === op.cardId);
    if (idx === -1) throw new Error(`Card not found`);
    fromCol.cards.splice(idx, 1);
  } else {
    fromCol.cards = [];
  }

  recalcColumn(fromCol);
}

function recalcColumn(col: Column): void {
  col.cardCount = col.cards.length;
}

The mutation happens on the copy - we already did structuredClone in the reducer. Errors get caught above. Counts always get recalculated.

Why cardCount if it’s the same as cards.length? Because the LLM makes decisions based on cardCount. If it’s stale, the agent decides wrong. Derived fields get recalculated where state gets mutated - in the reducer, not in the render.

Why operations compose

The reducer runs sequentially. Two tools emitting operations in the same step:

// Tool 1: { type: "move", cardId: "T-42", fromColumn: "backlog", toColumn: "in_progress" }
// → Reducer applies, state updated

// Tool 2: { type: "remove", cardId: "T-15", fromColumn: "backlog" }
// → Reducer applies again, state updated

Each operation sees state after the previous one. No conflicts. No manual merge. No stale reads.

With full replacement, both read the same original state, each produces its version, and the second overwrites the first. Goodbye T-42.

When to replace and when to operate

Full replacement when you’re creating state from scratch. buildBoard generates a new board. No previous state to modify. Returns a full Board. Any bootstrap where the field is null.

Operations when you’re modifying existing state. moveCard moves a card. removeCard removes a card. Any incremental edit.

The union BoardUpdate = Board | BoardOp lets both flow through the same reducer. No branching in the calling code.

Zod validation at the boundary

TypeScript types don’t save you when data comes from an LLM. They work at compile time. At runtime, you need more.

const MoveOpSchema = z.object({
  type: z.literal("move"),
  cardId: z.string().optional(),
  fromColumn: z.string(),
  toColumn: z.string(),
});

const RemoveOpSchema = z.object({
  type: z.literal("remove"),
  cardId: z.string().optional(),
  fromColumn: z.string(),
});

const BoardOpSchema = z.discriminatedUnion("type", [
  MoveOpSchema,
  RemoveOpSchema,
]);

function isBoardOp(value: unknown): value is BoardOp {
  const result = BoardOpSchema.safeParse(value);
  if (!result.success) {
    console.warn("Invalid operation", { error: result.error.message });
    return false;
  }
  return true;
}

z.discriminatedUnion on the type field. If the LLM sends a type that isn’t "move" or "remove", Zod rejects it. State stays untouched. No escape hatch.

And TypeScript marks the else as unreachable if you cover all operation types. No switch without default, no forgotten cases.

Wiring it up in LangGraph

LangGraph exposes custom reducers through withLangGraph():

const BoardStateSchema = z.object({
  messages: withLangGraph(z.custom<BaseMessage[]>(), MessagesZodMeta),
  board: withLangGraph(
    z.custom<Board | null>(),
    {
      default: () => null,
      reducer: {
        schema: z.custom<BoardUpdate>(),
        fn: boardReducer,
      },
    },
  ),
  phase: z.enum(["setup", "editing", "review", "complete"]).default("setup"),
});

When a node returns { board: someOp }, LangGraph doesn’t replace. It calls boardReducer(currentBoard, someOp).

The default is null. The first time buildBoard runs, it returns a full Board. The reducer validates and stores it. From there on, moveCard sends incremental operations.

What tutorials don’t tell you

CQRS in reducers isn’t something LangGraph gives you out of the box. You build it on top of their channel mechanism. But it’s what makes an agent with complex state work in production:

  • Nested state with derived fields → incremental operations
  • Multiple tools touching the same field → sequential reduction
  • Data from an LLM → Zod validation at the boundary
  • Invalid operations → reject and preserve, don’t crash

Defining operation types and writing a reducer takes more time than return { myField: newValue }. But you pay once. Corrupt state bugs you pay every time the LLM decides to invent a field.

What this enables: the UI as a window into agent state

Here’s the part tutorials don’t even mention.

Your agent has a board in its state. A React component (or any frontend) subscribes to that state and renders it. The user sees columns with cards. Now the user says “move T-42 to In Progress” and the agent runs moveCard. The tool returns an operation: { type: "move", cardId: "T-42", fromColumn: "backlog", toColumn: "in_progress" }.

The reducer applies it. State updates. The UI re-renders with the updated board.

But here’s the key: the UI and the agent share the same state. No manual sync. No separate PATCH endpoints. No “the UI does PUT /board and then the agent does PUT /board and oops the user’s changes are gone.”

The flow:

  1. The UI renders from the agent’s state.
  2. The user interacts → the agent runs a tool → the tool emits an operation → the reducer updates state → the UI updates.
  3. The agent reasons → runs another tool → emits another operation → the reducer applies on the already-updated state → the UI updates again.

Everything flows through the same channel. The UI isn’t a separate layer you need to keep in sync. It’s a window into the agent’s state. And because operations are incremental and the reducer validates them, the UI never sees corrupt state - it never renders a board with cardCount: 5 and three cards.

But there’s more. Because the UI knows about operations, it can emit them directly. A drag-and-drop in the UI can generate a BoardMoveOp. The user reorders columns, the UI emits the operation, the reducer applies it, and the agent sees the updated board on its next reasoning step. You don’t need two state systems - one for the UI and one for the agent. One reducer, one state, two sources of changes that compose naturally.

This also enables the human-in-the-loop flow. The agent proposes a board, the user reviews it, rejects it and says “change this.” The agent runs moveCard with the adjustments. The operation flows through the same reducer. No special cases. No separate code path for “the user edited” vs “the agent edited.”

And because the reducer rejects invalid operations, it doesn’t matter who emits the change - the UI, the agent, or an external webhook. If the operation doesn’t pass validation, state is preserved. The source of truth is always the state inside the graph.

This is what CQRS in reducers really enables: real-time collaboration between human and agent over shared state, with consistency guarantees, no logic duplication, and no way for the LLM to corrupt state because the reducer is the boundary.

If your agent just chats, you don’t need this. But if your agent and a human work together on complex state - a board, a calendar, a plan, a distribution - incremental operations with a CQRS reducer are what makes it work without blowing up.

TL;DR

SituationWhat to do
Create from scratchFull replacement (Board)
Modify existingOperation (BoardMoveOp, BoardRemoveOp)
Multiple tools, same fieldSequential reducer
Data from an LLMz.discriminatedUnion at the boundary
Derived fieldsRecalculate on every mutation
Invalid operationLog, reject, preserve state

Further reading