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.phdm.zip>to create the necessary TypeScript types, action creators, and, crucially, the skeleton file for your reducers (typicallydocument-models/<YourModelName>/src/reducers/<your-model-name>.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.,{ id: '1', 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. We'll explore techniques for immutability shortly.
- 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/to-do-list/src/reducers/to-do-list.ts, you'll find an object structure like this:import { ToDoListToDoListOperations } from "../../gen/to-do-list/operations.js"; // Generated type for operations
import { ToDoListState } from "../../gen/types.js"; // Generated type for state
export const reducer: ToDoListToDoListOperations = {
addTodoItemOperation(state: ToDoListState, action, dispatch) {
// Your logic for ADD_TODO_ITEM
// ...
return newState;
},
updateTodoItemOperation(state: ToDoListState, action, dispatch) {
// Your logic for UPDATE_TODO_ITEM
// ...
return newState;
},
deleteTodoItemOperation(state: ToDoListState, action, dispatch) {
// Your logic for DELETE_TODO_ITEM
// ...
return newState;
},
// ... other operations
};The
ToDoListToDoListOperationstype (or similar, depending on your model name) 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.The
dispatchparameter is an advanced feature allowing a reducer to trigger subsequent operations. While powerful for complex workflows, it's often not needed for basic operations and can be ignored if unused.
Implementing reducer logic: A practical guide
Let's use our familiar ToDoList example to illustrate common patterns. For this example, we'll assume our state schema has been updated to include a stats object to track the number of total, checked, and unchecked items.
Our ToDoListState now looks like this:
interface ToDoItem {
id: string;
text: string;
checked: boolean;
}
interface ToDoListStats {
total: number;
checked: number;
unchecked: number;
}
interface ToDoListState {
items: ToDoItem[];
stats: ToDoListStats;
}
And our action creators (from ../../gen/creators or ../../gen/operations.js) provide actions like:
actions.addTodoItem({ id: 'some-id', text: 'New Task' })actions.updateTodoItem({ id: 'item-id', text: 'Updated Task Text', checked: true })actions.deleteTodoItem({ id: 'item-id' })
1. Adding an item (e.g., addTodoItemOperation)
To add a new item to the items array immutably:
addTodoItemOperation(state: ToDoListState, action: /* AddTodoItemActionType */ any, dispatch) {
const newItem: ToDoItem = {
id: action.input.id,
text: action.input.text,
checked: false, // New items default to unchecked
};
// Return a new state object
return {
...state, // Copy all existing properties from the current state
items: [...state.items, newItem], // Create a new items array: spread existing items, add the new one
};
}
Explanation:
- We use the spread operator (
...state) to copy top-level properties from the old state into the new state object. - For the
itemsarray, we create a new array by spreading the existingstate.itemsand then appending thenewItem.
2. Updating an item (e.g., updateTodoItemOperation)
To update an existing item in the items array immutably:
updateTodoItemOperation(state: ToDoListState, action: /* UpdateTodoItemActionType */ any, dispatch) {
const { id, text, checked } = action.input;
// Return a new state object
return {
...state,
items: state.items.map(item => {
if (item.id === id) {
// This is the item to update. Return a *new* item object.
return {
...item, // Copy existing properties of the item
// Update only fields that are provided in the action input
...(text !== undefined && { text: text }),
...(checked !== undefined && { checked: checked }),
};
}
// This is not the item we're looking for, return it unchanged.
return item;
}),
};
}
Explanation:
- We use the
maparray method, which always returns a new array. - For the item that matches
action.input.id, we create a new item object using the spread operator (...item) and then overwrite the properties (text,checked) that are present inaction.input. - The conditional spread (
...(condition && { property: value })) is a concise way to only include a property in the new object if its corresponding input value is provided. This elegantly handles partial updates. - If an item doesn't match the ID, it's returned as is.
Error Handling Note: In a real application, you might want to add a check to see if an item with action.input.id actually exists. If not, you could throw an error or handle it according to your application's requirements:
// Inside updateTodoItemOperation, before returning:
const itemToUpdate = state.items.find((item) => item.id === action.input.id);
if (!itemToUpdate) {
// Option 1: Throw an error (Powerhouse runtime might catch this)
throw new Error(`Item with id ${action.input.id} not found.`);
// Option 2: Return current state (no change)
// return state;
}
// ... proceed with map
3. Deleting an item (e.g., deleteTodoItemOperation)
To remove an item from the items array immutably:
deleteTodoItemOperation(state: ToDoListState, action: /* DeleteTodoItemActionType */ any, dispatch) {
const { id } = action.input;
// Return a new state object
return {
...state,
items: state.items.filter(item => item.id !== id), // Create a new array excluding the item to delete
};
}
Explanation:
- We use the
filterarray method, which returns a new array containing only the elements for which the callback function returnstrue.
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 {
ToDoListState,
AddTodoItemInput, // Generated input type
// ... other types
} from "../../gen/types.js";
import { ToDoListToDoListOperations } from "../../gen/to-do-list/operations.js"; // Generated operations type
// Define the type for the action more explicitly if needed, or rely on inferred types
// from ToDoListToDoListOperations. For complex actions, defining specific action types can be beneficial.
// For example:
// interface AddTodoItemAction {
// type: 'ADD_TODO_ITEM'; // Or the specific string constant used by the action creator
// input: AddTodoItemInput;
// }
export const reducer: ToDoListToDoListOperations = {
addTodoItemOperation(
state: ToDoListState,
action: { input: AddTodoItemInput /* plus type property */ },
dispatch,
) {
// Now 'action.input.text' and 'action.input.id' are type-checked
const newItem = {
id: action.input.id,
text: action.input.text,
checked: false,
};
return {
...state,
items: [...state.items, newItem],
};
},
// ... 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.phdm.zip to scaffold your document model's code.
Implement the operation reducers
Navigate to document-models/to-do-list/src/reducers/to-do-list.ts. The generator will have created a skeleton file. Replace its contents with the following logic.
import { ToDoListToDoListOperations } from "../../gen/to-do-list/operations.js";
import { ToDoListState } from "../../gen/types.js"; // Assuming this now includes the 'stats' object
// REMARKS: This is our main reducer object. It implements all operations defined in the schema.
// The ToDoListToDoListOperations type is auto-generated from our GraphQL specification and ensures type safety.
export const reducer: ToDoListToDoListOperations = {
// REMARKS: The addTodoItemOperation adds a new item and updates our tracking statistics.
// - state: The current document state. Powerhouse uses a library like Immer.js,
// so you can write code that looks like it's mutating the state directly.
// Behind the scenes, Powerhouse ensures this results in an immutable update.
// - action: Contains the operation's 'type' and 'input' data from the client.
// - dispatch: A function to trigger subsequent operations (advanced, not used here).
addTodoItemOperation(state, action, dispatch) {
// REMARKS: We update our statistics for total and unchecked items.
state.stats.total += 1;
state.stats.unchecked += 1;
// REMARKS: We push the new to-do item into the items array.
// The data for the new item comes from the operation's input.
state.items.push({
id: action.input.id,
text: action.input.text,
checked: false, // New items always start as unchecked.
});
},
// REMARKS: The updateTodoItemOperation modifies an existing to-do item.
// It handles partial updates for text and checked status.
updateTodoItemOperation(state, action, dispatch) {
// REMARKS: First, we find the specific item we want to update using its ID.
const item = state.items.find((item) => item.id === action.input.id);
// REMARKS: It's good practice to handle cases where the item might not be found.
if (!item) {
throw new Error(`Item with id ${action.input.id} not found`);
}
// REMARKS: We only update the text if it was provided in the input.
// This allows for partial updates (e.g., just checking an item without changing its text).
if (action.input.text) {
item.text = action.input.text;
}
// REMARKS: When the checked status changes, we also update our statistics.
// We check for `true` and `false` explicitly.
if (action.input.checked) {
// This is true only if action.input.checked is true
// Note: This assumes the item was previously unchecked. For a more robust implementation,
// you could check `if (item.checked === false)` before updating stats to prevent inconsistencies.
state.stats.unchecked -= 1;
state.stats.checked += 1;
item.checked = action.input.checked;
}
if (action.input.checked === false) {
// Note: This assumes the item was previously checked.
state.stats.unchecked += 1;
state.stats.checked -= 1;
item.checked = action.input.checked;
}
},
// REMARKS: The deleteTodoItemOperation removes an item from the list.
deleteTodoItemOperation(state, action, dispatch) {
// REMARKS: Before removing the item, we find it to determine its checked status.
// This is necessary to correctly decrement our statistics.
const item = state.items.find((item) => item.id === action.input.id);
// REMARKS: We always decrement the total count.
state.stats.total -= 1;
// REMARKS: We then decrement the 'checked' or 'unchecked' count based on the item's status.
if (item?.checked) {
// This is shorthand for item?.checked === true
state.stats.checked -= 1;
}
if (item?.checked === false) {
state.stats.unchecked -= 1;
}
// REMARKS: Finally, we create a new 'items' array that excludes the deleted item.
// Assigning to 'state.items' is handled by Powerhouse to produce a new immutable state.
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.