vroqjs.com

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.