07-debuggable-state/03-reducer-boundaries.md
Reducer boundaries
Reducer boundaries
Reducer boundaries define **which reducer owns which slice of state**.
Clear reducer boundaries are critical for keeping Vroq apps maintainable and debuggable.
Feature ownership
Each feature should usually own one reducer that manages the feature's state slice.
Example:
features/files/filesReducer.js
features/editor/editorReducer.js
Reducers are then registered in configureStore.js.
Reducers should only modify their own slice
A reducer should:
- update only the state slice it owns
- respond to actions relevant to that slice
- avoid reaching deeply into unrelated state
Important rule:
Reducers receive the whole root state, not only their slice.
That means a feature reducer must explicitly read its own slice from the root state and write the updated slice back to the root object.
Example:
export function counterReducer(state, action) {
const root = state || {};
const counter = root.counter || { value: 0 };
switch (action?.type) {
case "COUNTER_INCREMENT":
return {
...root,
counter: {
...counter,
value: (counter.value || 0) + 1
}
};
default:
return state;
}
}
Bad example:
export function counterReducer(state, action) {
const s = state || { value: 0 };
switch (action?.type) {
case "COUNTER_INCREMENT":
return {
...s,
value: (s.value || 0) + 1
};
default:
return state;
}
}
This bad pattern mutates the root object as if it were the feature slice, which produces the wrong state shape.
Bad result:
{
counter: { value: 0 },
value: 1
}
Good example:
- each feature reducer managing only its own part of the state tree
Why boundaries matter
Clear boundaries make it easier to:
- find where a state bug belongs
- isolate feature behavior
- avoid cross-feature coupling
- inspect state changes through actions
sliceReducer helper
When a reducer should own one top-level feature slice, prefer using sliceReducer(...).
Example:
import { sliceReducer } from "/vroqjs/system/store/sliceReducer.js";
export const counterReducer = sliceReducer("counter", (counter, action) => {
const s = counter || { value: 0 };
switch (action?.type) {
case "COUNTER_INCREMENT":
return {
...s,
value: (s.value || 0) + 1
};
default:
return counter;
}
});
sliceReducer(...) extracts the owned slice from root state, runs the feature reducer on that slice, then writes the result back to the root state.
This prevents a common LLM mistake where a feature reducer accidentally mutates the root state as if it were the feature slice.
Use sliceReducer(...) when the reducer owns one top-level feature slice such as counter, editor, files, or settings.
Final rule
Reducers should manage **one clear state slice owned by one feature**, and state transitions should stay inside that boundary.