core/reducers

Use when building Themis createReducer handlers, chaining actions, or handling immutable canonical-state updates and no-op reference equality.

SKILL.md

GitHub

Reducers — createReducer

Operational guidance for reducer implementation. Full API walkthrough and examples: @themislib/themis/docs/REDUCERS.md → Creating Reducers. Public API: @themislib/themis/utils/store/create-reducer; related guidance: Actions — Do and Testing — Layer rules.

Use when

  • Adding or changing a slice reducer built with createReducer<State>(initialState).
  • Registering action handlers with .with(actionCreator, handler).
  • Reviewing reducer purity, canonical state, or no-op reference behavior.

Do

  • Keep handlers pure and immutable; return new objects only for actual state changes.
  • Return the incoming state reference when a handler is a no-op.
  • Use action creators from the canonical owner; register request/success/failure handlers for async actions as needed.
  • Store source facts only: collections, ids, relationships, request status, and plain errors.
  • Put expensive work, generated values, persistence, and external calls in actions or sagas, not reducers.
  • Register slice reducers with the store owner rather than creating parallel reducer trees.

Don't

  • Do not mutate state or collection internals.
  • Do not call Date.now(), Math.random(), fetch, localStorage, or other side-effect APIs inside reducer handlers.
  • Do not store derived fields such as filtered*, sorted*, *Count, has*, or selected entity copies when selectors can compute them.
  • Do not add new helper functions before searching existing utilities and documenting why reuse/extension is not enough.

Examples

Chain handlers on the reducer function

import { createAction } from "@themislib/themis/utils/store/create-action";
import { createReducer } from "@themislib/themis/utils/store/create-reducer";

type CounterState = { value: number };
const increment = createAction<[amount: number]>("counter/increment");

export const counterReducer = createReducer<CounterState>({ value: 0 }).with(
  increment,
  (state, { payload: [amount] }) => ({ value: state.value + amount })
);

Immutable collection update with parent no-op guard

import { type Collection, createCollection, updateItem } from "@themislib/themis/utils/collections/collection-utils";
import { createAction } from "@themislib/themis/utils/store/create-action";
import { createReducer } from "@themislib/themis/utils/store/create-reducer";

type Todo = { id: string; title: string };
type TodosState = { todos: Collection<Todo, "id"> };
const initialState: TodosState = { todos: createCollection<Todo, "id">("id") };
const renameTodo = createAction("todos/rename", (id: string, title: string) => ({ id, title }));

export const todosReducer = createReducer(initialState).with(renameTodo, (state, { payload }) => {
  const todos = updateItem(state.todos, { id: payload.id, title: payload.title });
  return todos === state.todos ? state : { ...state, todos };
});

Explicit same-reference no-op for nested state

import { createAction } from "@themislib/themis/utils/store/create-action";
import { createReducer } from "@themislib/themis/utils/store/create-reducer";

const setSearch = createAction<[query: string]>("todos/setSearch");

export const reducer = createReducer({ filters: { query: "" } }).with(
  setSearch,
  (state, { payload: [query] }) =>
    state.filters.query === query ? state : { ...state, filters: { ...state.filters, query } }
);

Async request, success, and failure handlers

import { createAsyncAction } from "@themislib/themis/utils/store/create-action";
import { createReducer } from "@themislib/themis/utils/store/create-reducer";

type Todo = { id: string; title: string };
const loadTodo = createAsyncAction<[id: string], { id: string }, Todo>("todos/loadAsync", "todos/load", (id) => ({ id }));

export const reducer = createReducer({ loading: false, todo: undefined as Todo | undefined, error: "" })
  .with(loadTodo, (state) => ({ ...state, loading: true, error: "" }))
  .with(loadTodo.success, (state, { payload }) => ({ ...state, loading: false, todo: payload.response }))
  .with(loadTodo.failure, (state, { payload }) => ({ ...state, loading: false, error: payload.error.message }));

❌ Bad: mutation and reducer-created nondeterminism

import { type Collection, createCollection } from "@themislib/themis/utils/collections/collection-utils";
import { createAction } from "@themislib/themis/utils/store/create-action";
import { createReducer } from "@themislib/themis/utils/store/create-reducer";

type Todo = { id: string; title: string };
type TodosState = { todos: Collection<Todo, "id">; updatedAtMs: number };
const initialState: TodosState = { todos: createCollection<Todo, "id">("id"), updatedAtMs: 0 };
const renameTodo = createAction("todos/rename", (id: string, title: string) => ({ id, title }));

// BAD: mutation hides changes from reference checks and Date.now() makes replay nondeterministic.
export const reducer = createReducer(initialState).with(renameTodo, (state, { payload }) => {
  state.todos.map[payload.id].title = payload.title;
  state.updatedAtMs = Date.now();
  return state;
});

❌ Bad: unconditional nested copies lose no-op identity

import { createAction } from "@themislib/themis/utils/store/create-action";
import { createReducer } from "@themislib/themis/utils/store/create-reducer";

const setSearch = createAction<[query: string]>("todos/setSearch");

// BAD: createReducer only shallow-compares the top-level state object.
export const reducer = createReducer({ filters: { query: "" } }).with(setSearch, (state, { payload: [query] }) => ({
  ...state,
  filters: { ...state.filters, query },
}));

Implementation cues

  • createReducer returns the reducer function itself; .with() mutates the builder registration and returns the same reducer for chaining.
  • reducer.initialState is available for tests.
  • Collection helpers often already preserve reference equality on no-op; wrap parent state updates so the parent also preserves the same reference.
  • Helpers like createBooleanPreference.register(builder) can compose handlers into an existing reducer builder.

Verification cues

  • Reducer tests call the reducer directly: initial state, each action handler, async success/failure branches, and unknown/no-op action reference equality.
  • Serialization tests cover new state shape when fields are added.
  • State-integrity handoff lists searches for duplicate state/actions/selectors/sagas and identifies the canonical owner.

See also

  • @themislib/themis/docs/REDUCERS.md — human reference for reducer and action examples.
  • core/actions/SKILL.md — action creator contracts consumed by .with().
  • core/collections/SKILL.md — immutable entity updates.
  • Selected Store family selector skill — derived values belong in selectors.
  • core/state-serialization/SKILL.md — serializable state rules.
  • core/testing/SKILL.md — reducer test expectations.