Implement document reducers
The heart of document logic
In our journey through Powerhouse Document Model creation, we've defined the "what" – the structure of our data (State Schema) and the ways it can be changed (Document Operations). We've also seen how the Document Model Generator translates these specifications into a coded scaffold. Now, we arrive at the "how": implementing Document Reducers.
Reducers are the core logic units of your document model. They are the functions that take the current state of your document and an operation (an "action"), and then determine the new state of the document. They are the embodiment of your business rules and the engine that drives state transitions in a predictable, auditable, and immutable way.
Recap: The journey to reducer implementation
Before diving into the specifics of writing reducers, let's recall the preceding steps:
- State Schema Definition: You designed the GraphQL
typedefinitions for your document's data structure (e.g.,TodoListState,TodoItem). - Document Operation Specification: You defined the GraphQL
inputtypes that specify the parameters for each allowed modification to your document (e.g.,AddTodoItemInput,UpdateTodoItemInput). These were then associated with named operations (e.g.,ADD_TODO_ITEM) in the Connect application. - Code Generation: You used
ph generate <YourModelName.phd>to create the necessary TypeScript types, action creators, and, crucially, the skeleton file for your reducers (typicallydocument-models/<your-model-name>/src/reducers/todos.ts).
This generated reducer file is our starting point. It will contain function stubs or an object structure expecting your reducer implementations, all typed according to your schema.
What is a reducer? The core principles
In the context of Powerhouse and inspired by patterns like Redux, a reducer is a pure function with the following signature (conceptually):
(currentState, action) => newState
Let's break down its components and principles:
currentState: This is the complete, current state of your document model instance before the operation is applied. It's crucial to treat this as immutable.action: This is an object describing the operation to be performed. It typically has:- A
typeproperty: A string identifying the operation (e.g.,'ADD_TODO_ITEM'). - An
inputproperty (or similar, likepayload): An object containing the data necessary for the operation, matching the GraphQLinputtype you defined (e.g.,{ text: 'Buy groceries' }forAddTodoItemInput).
- A
newState: The reducer must return a new state object representing the state after the operation has been applied. If the operation does not result in a state change, the reducer should return thecurrentStateobject itself.
Key principles guiding reducer implementation:
-
Purity:
- Deterministic: Given the same
currentStateandaction, a reducer must always produce the samenewState. - No Side Effects: Reducers must not perform any side effects. This means no API calls, no direct DOM manipulation, no
Math.random()(unless seeded deterministically for specific testing scenarios), and no modification of variables outside their own scope. Their sole job is to compute the next state.
- Deterministic: Given the same
-
Immutability:
- Never Mutate
currentState: You must never directly modify thecurrentStateobject or any of its nested properties. - Always Return a New Object for Changes: If the state changes, you must create and return a brand new object. If the state does not change, you return the original
currentStateobject. - This is fundamental to Powerhouse's event sourcing architecture, enabling time travel, efficient change detection, and a clear audit trail.
Powerhouse uses Immer.jsPowerhouse uses Immer.js under the hood, which means you can write code that looks like it's mutating the state directly (e.g.,
state.items.push(...)), but Immer ensures it results in an immutable update. This gives you the best of both worlds: readable code and immutable state. - Never Mutate
-
Single Source of Truth: The document state managed by reducers is the single source of truth for that document instance. All UI rendering and data queries are derived from this state.
-
Delegation to specific operation handlers: While you can write one large reducer that uses a
switchstatement orif/else ifblocks based onaction.type, Powerhouse's generated code typically encourages a more modular approach. You'll often implement a separate function for each operation, which are then combined into a main reducer object or map. Theph generatecommand usually sets up this structure for you. For example, in yourdocument-models/todo-list/src/reducers/todos.ts, you'll find an object structure like this:import type { TodoListTodosOperations } from "todo-tutorial/document-models/todo-list";
export const todoListTodosOperations: TodoListTodosOperations = {
addTodoItemOperation(state, action) {
// Your logic for ADD_TODO_ITEM
},
updateTodoItemOperation(state, action) {
// Your logic for UPDATE_TODO_ITEM
},
deleteTodoItemOperation(state, action) {
// Your logic for DELETE_TODO_ITEM
},
};The
TodoListTodosOperationstype is generated by Powerhouse and ensures your reducer object correctly implements all defined operations. Thestateandactionparameters within these methods will also be strongly typed based on your schema.
Implementing reducer logic: A practical guide
Let's use our familiar TodoList example to illustrate common patterns.
Basic implementation (matching Get Started)
The basic implementation matches what you built in the Get Started tutorial:
import { generateId } from "document-model/core";
import type { TodoListTodosOperations } from "todo-tutorial/document-models/todo-list";
export const todoListTodosOperations: TodoListTodosOperations = {
addTodoItemOperation(state, action) {
// Generate a unique ID for the new todo item
const id = generateId();
// Add the new item to the state (Immer handles immutability)
state.items.push({ ...action.input, id, checked: false });
},
updateTodoItemOperation(state, action) {
// Find the item to update by its ID
const item = state.items.find((item) => item.id === action.input.id);
// Return early if item not found
if (!item) return;
// Update only the fields that are provided (partial update)
item.text = action.input.text ?? item.text;
item.checked = action.input.checked ?? item.checked;
},
deleteTodoItemOperation(state, action) {
// Filter out the item with the matching ID
state.items = state.items.filter((item) => item.id !== action.input.id);
},
};
Notice that addTodoItemOperation uses generateId() from document-model/core to create a unique ID. This is the recommended pattern — the ID is generated in the reducer, not passed from the UI. This ensures consistent, unique IDs across all operations.
Advanced implementation (with statistics tracking)
This section extends the basic reducers with statistics tracking, matching the advanced schema from the previous section. This demonstrates how to update computed/derived state alongside your primary data.
For the advanced version with stats, we need to update the statistics whenever items are added, updated, or deleted:
import { generateId } from "document-model/core";
import type { TodoListTodosOperations } from "todo-tutorial/document-models/todo-list";
export const todoListTodosOperations: TodoListTodosOperations = {
addTodoItemOperation(state, action) {
// Generate a unique ID for the new todo item
const id = generateId();
// Update statistics
state.stats.total += 1;
state.stats.unchecked += 1;
// Add the new item to the state
state.items.push({
id,
text: action.input.text,
checked: false, // New items always start as unchecked
});
},
updateTodoItemOperation(state, action) {
// Find the specific item we want to update
const item = state.items.find((item) => item.id === action.input.id);
if (!item) {
throw new Error(`Item with id ${action.input.id} not found`);
}
// Update text if provided
if (action.input.text !== undefined) {
item.text = action.input.text;
}
// Handle checked status changes and update stats
if (action.input.checked !== undefined && action.input.checked !== item.checked) {
if (action.input.checked) {
state.stats.unchecked -= 1;
state.stats.checked += 1;
} else {
state.stats.unchecked += 1;
state.stats.checked -= 1;
}
item.checked = action.input.checked;
}
},
deleteTodoItemOperation(state, action) {
// Find the item to determine its checked status for stats
const item = state.items.find((item) => item.id === action.input.id);
if (item) {
// Update statistics
state.stats.total -= 1;
if (item.checked) {
state.stats.checked -= 1;
} else {
state.stats.unchecked -= 1;
}
}
// Remove the item from the list
state.items = state.items.filter((item) => item.id !== action.input.id);
},
};
Common patterns explained
1. Adding an item
addTodoItemOperation(state, action) {
const id = generateId(); // Generate unique ID
state.items.push({ ...action.input, id, checked: false });
}
- We use
generateId()to create a unique identifier - We spread
action.inputto get the text, add the generated ID and defaultchecked: false - With Immer, this "mutation" is actually immutable
2. Updating an item
updateTodoItemOperation(state, action) {
const item = state.items.find((item) => item.id === action.input.id);
if (!item) return;
item.text = action.input.text ?? item.text;
item.checked = action.input.checked ?? item.checked;
}
- We find the item by ID
- We use nullish coalescing (
??) to only update fields that were provided - This allows partial updates (e.g., just changing
checkedwithout touchingtext)
3. Deleting an item
deleteTodoItemOperation(state, action) {
state.items = state.items.filter((item) => item.id !== action.input.id);
}
- We use
filterto create a new array without the deleted item - Immer handles making this immutable
Leveraging generated types
As highlighted in Using the Document Model Generator, ph generate produces TypeScript types for your state (e.g., TodoListState, TodoItem) and the inputs for your operations (e.g., AddTodoItemInput, UpdateTodoItemInput).
Always use these generated types in your reducer implementations!
import { generateId } from "document-model/core";
import type { TodoListTodosOperations } from "todo-tutorial/document-models/todo-list";
export const todoListTodosOperations: TodoListTodosOperations = {
addTodoItemOperation(state, action) {
// TypeScript knows action.input has { text: string }
const id = generateId();
state.items.push({ id, text: action.input.text, checked: false });
},
// ... other reducers
};
Using these types provides:
- Compile-time safety: Catch errors related to incorrect property names or data types before runtime.
- Autocompletion and IntelliSense: Improved developer experience in your IDE.
- Clearer code: Types serve as documentation for the expected data structures.
Practical implementation: Writing the TodoList reducers
Now that you understand the principles, let's put them into practice by implementing the reducers for our TodoList document model.
Tutorial: Implementing the TodoList reducers
This tutorial assumes you have followed the steps in the previous chapters, especially using ph generate TodoList.phd to scaffold your document model's code.
Implement the operation reducers
Navigate to document-models/todo-list/src/reducers/todos.ts. The generator will have created a skeleton file. Replace its contents with the following logic.
Basic version (without stats):
import { generateId } from "document-model/core";
import type { TodoListTodosOperations } from "todo-tutorial/document-models/todo-list";
export const todoListTodosOperations: TodoListTodosOperations = {
addTodoItemOperation(state, action) {
const id = generateId();
state.items.push({ ...action.input, id, checked: false });
},
updateTodoItemOperation(state, action) {
const item = state.items.find((item) => item.id === action.input.id);
if (!item) return;
item.text = action.input.text ?? item.text;
item.checked = action.input.checked ?? item.checked;
},
deleteTodoItemOperation(state, action) {
state.items = state.items.filter((item) => item.id !== action.input.id);
},
};
Advanced version (with stats):
import { generateId } from "document-model/core";
import type { TodoListTodosOperations } from "todo-tutorial/document-models/todo-list";
export const todoListTodosOperations: TodoListTodosOperations = {
addTodoItemOperation(state, action) {
const id = generateId();
state.stats.total += 1;
state.stats.unchecked += 1;
state.items.push({
id,
text: action.input.text,
checked: false,
});
},
updateTodoItemOperation(state, action) {
const item = state.items.find((item) => item.id === action.input.id);
if (!item) {
throw new Error(`Item with id ${action.input.id} not found`);
}
if (action.input.text !== undefined) {
item.text = action.input.text;
}
if (action.input.checked !== undefined && action.input.checked !== item.checked) {
if (action.input.checked) {
state.stats.unchecked -= 1;
state.stats.checked += 1;
} else {
state.stats.unchecked += 1;
state.stats.checked -= 1;
}
item.checked = action.input.checked;
}
},
deleteTodoItemOperation(state, action) {
const item = state.items.find((item) => item.id === action.input.id);
if (item) {
state.stats.total -= 1;
if (item.checked) {
state.stats.checked -= 1;
} else {
state.stats.unchecked -= 1;
}
}
state.items = state.items.filter((item) => item.id !== action.input.id);
},
};
Reducers and the event sourcing model
Every time a reducer processes an operation and returns a new state, Powerhouse records the original operation (the "event") in an append-only log associated with the document instance. The current state of the document is effectively a "fold" or "reduction" of all past events, applied sequentially by the reducers.
This is why purity and immutability are so critical:
- Purity ensures that replaying the same sequence of events will always yield the exact same final state.
- Immutability ensures that each event clearly defines a discrete state transition, making it easy to audit changes and understand the document's history.
Conclusion
Implementing document reducers is where you breathe life into your document model's specification. By adhering to the principles of purity and immutability, and by leveraging the type safety provided by Powerhouse's code generation, you can build predictable, testable, and maintainable business logic. These reducers form the immutable backbone of your document's state management, perfectly aligning with the event sourcing architecture that underpins Powerhouse.
With your reducers implemented, your document model is now functionally complete from a data manipulation perspective. The next chapter covers how to write tests for this logic to ensure its correctness and reliability.