Why CRUD, when we can use ChangeActions?
Traditional CRUD operations work well for single-item changes, but they fall apart when you need to manage multiple changes at once. What if you could batch all your creates, updates, and deletes into a single request? Enter ChangeActions.
Here's an interactive demo. Try making multiple changes to the table below:
- Add new items using the "Add Item" button
- Update existing items by double-clicking on the name or age columns
- Delete items using the delete button
Make several changes, then click "Save" to see how all your actions are tracked together:
Notice how all your changes are aggregated into a single data structure. Instead of making three separate API calls (one for each CRUD operation), you get one clean payload:
Example of Change Actions
{
"add": [
{
"name": "Philip",
"age": 20
}
],
"update": {
"2": {
"id": "2",
"name": "Griffin",
"age": 25
}
},
"delete": [
"3"
]
}
This single object captures everything: new items to create, existing items to modify, and items to remove.
What are ChangeActions?
ChangeActions is a pattern that batches multiple CRUD operations into a single, atomic request. Remember the CRUD acronym?
- Create (C) - Add new records
- Read (R) - Fetch existing records
- Update (U) - Modify existing records
- Delete (D) - Remove records
ChangeActions takes the CUD part and combines them into one cohesive type:
type ChangeActions<T, ID extends string | number | symbol> = {
add: T[]; // Items to create
update: Record<ID, T>; // Items to update (keyed by ID)
delete: ID[]; // IDs of items to delete
};
For our demo above, the type would be:
ChangeActions<{ name: string; age: number }, string>
which tracks all changes to our list of people.
How to use ChangeActions?
Once your frontend captures all the changes, you send them to your backend as a single payload. The backend then processes each action type in order. Here's a clean implementation:
class ChangeActionsHandler {
async handle(changeActions: ChangeActions<{ name: string; age: number }>) {
// Process deletes first to avoid conflicts
if (changeActions.delete.length > 0) {
await this.repository.deleteMany(changeActions.delete);
}
// Update existing items
for (const [id, update] of Object.entries(changeActions.update)) {
await this.repository.update(id, update);
}
// Finally, add new items
if (changeActions.add.length > 0) {
await this.repository.insertMany(changeActions.add);
}
// Return the updated list
return this.repository.getAll();
}
}
I'm sure a smarter person than I could figure out how to do this more efficiently, perhaps using a database transaction, and combining the adds, updates and deletes into a single db operation. But this is a good starting point.
Key benefits:
- One HTTP request instead of multiple roundtrips
- Transactional - all changes succeed or fail together
- Efficient - batch operations are faster than individual calls
- Simpler API - one
ExecuteChangeActions
endpoint handles all changes
When to use ChangeActions?
ChangeActions shines in scenarios where users make multiple edits before saving:
Perfect Use Cases
- Spreadsheet-like interfaces - Users edit multiple cells before hitting "Save"
- Form builders - Adding, reordering, and removing form fields
- Task management - Batch updating task statuses, assignees, and priorities
- Data import tools - Reviewing and modifying imported records before committing
When NOT to Use
- Single-item operations - If users only ever change one thing at a time, stick with traditional CRUD
- Simple forms - Basic create/edit forms don't need this complexity
Wrapping Up
The ChangeActions pattern isn't about replacing CRUD entirely. It's about optimizing the user experience when multiple changes happen together. By batching operations, you reduce network overhead, simplify error handling, and create a more intuitive "edit session" workflow.
Try the demo above and see how natural it feels to make several changes before committing them all at once. That's the power of ChangeActions.
Enjoy!