Redux + Saga state management
for AI-first development.
Shared state, async workflows, and derived data in explicit, testable Redux + Saga building blocks. Svelte readables, React signals, or Kefir streams — pick your reactive primitive.
Install
npm install @augmentcode/themisredux, redux-saga, typed-redux-saga, and fast-equals are bundled as direct dependencies. All framework peers (svelte, react, kefir) are optional.
Why Themis exists
Themis started as a narrow connector: a thin bridge between redux and Svelte's reactive store model, paired with a small utility layer for running and supervising sagas. It solved one problem — wiring Redux state into Svelte components without manual subscriptions — and nothing more.
As the project matured, the selector layer grew into something more substantial. Proxy-based dependency tracking meant selectors recorded exactly which state paths they read at runtime. Combined with frame-rate coalescing, this produced selectors that were automatically memoized and throttled — noreselect, no manual dependency arrays, no hand-tuned memoization. The performance floor was raised by default, and a tracing mode was added to surface the slowest selectors in a running application without any instrumentation overhead.
Sagas were kept — and deepened — because they are the right tool for side effects. A saga runs outside the component tree. It owns the business logic: when to fetch, when to retry, when to cancel, when to stream. Components become declarative shells that read state and dispatch actions; the saga handles everything that follows. This separation means the rendering cycle and the effect cycle can evolve independently, and neither one bleeds into the other.
A library with this surface area needs guardrails. The ESLint plugin and the AI skills bundle were written to encode the architecture so it could be applied consistently — by engineers learning the pattern, and by AI agents generating code. The tradeoff is intentional: more structure up front, in exchange for a codebase that remains coherent at any scale.
Three store variants
Pick your reactive primitive. Store returns Svelte Readables. ReactStore returns Preact ReadonlySignals with .useValue() for hooks. StreamingStore returns Kefir Observables for Node, workers, and CLI consumers.
Proxy-based memoization
Selectors record exactly which state paths they access via a Proxy. Re-computation only happens when those specific paths change — not on every Redux dispatch.
Frame-rate coalescing
Rapid Redux writes are batched and emitted at most once per frame. Configurable from 1–256 FPS via throttledSelectorFrequency. No manual debounce or scheduler wrappers needed.
Auto-restarting saga manager
App sagas are supervised by a built-in manager. Crashes trigger exponential-backoff restarts (1s → 2s → 4s → … up to 10 min). Reference-counted so shared sagas only stop when every caller cancels.
Normalized collections
Entity state with O(1) ID lookups, insertion-order preservation, and reference counting for automatic cleanup. Immutable helpers for add, update, upsert, remove, filter, and batch operations.
ESLint plugin suite + AI skills
40+ custom lint rules enforcing architecture: no inline selectors in sagas, unique action types, one selectors file per slice, no wildcard saga takes, typed-redux-saga yield* style, and more.
One selector, every context
A single store.createSelector() call produces a selector that works in components, hooks, sagas, tests, and event handlers — each through the right escape hatch.
| Variant | Call | Returns | Component reads as |
|---|---|---|---|
| Store (Svelte) | selectCount() | Readable<number> | $count |
| ReactStore | selectCount() | ReadonlySignal<number> | signal.value |
| ReactStore | selectCount.useValue() | number | count (hook) |
| StreamingStore | selectCount() | Observable<number> | .observe(cb) |
| Any | selectCount.select(state) | number | — |
| Any (saga) | yield* selectCount.effect() | SagaGenerator<number> | — |
Quick look
A Svelte store wired with a counter slice and a saga. The same pattern applies to ReactStore and StreamingStore.
import { Store } from '@augmentcode/themis/svelte-store';
import { counterReducer } from './slices/counter/counter-slice';
import { counterSaga } from './slices/counter/sagas/counter-saga';
export const store = new Store(
{ counter: counterReducer },
undefined,
{ throttledSelectorFrequency: 64, sagaMonitor: true }
);
// Selectors — proxy-tracked, coalesced at 64 FPS
export const selectCount = store.createSelector(
(state) => state.counter.count
);
// In your Svelte component:
// const count = selectCount(); → Svelte Readable<number>
// <p>{$count}</p>Sagas that never stay crashed
Every saga you start via store.runSaga(fn) is supervised by a built-in manager. On crash, it records the error in Redux state, waits with exponential backoff (1 s → 2 s → 4 s → … capped at 10 min), and restarts automatically. Multiple components can callstore.runSaga(fn) for the same saga — reference counting ensures it only stops when every caller has cancelled.
Normalized collections, built in
The Collection<T, K> type stores entities by ID with O(1) lookup, insertion-order preservation, and an optional reference counter for automatic cleanup. All helpers are immutable — they return new collections, never mutate.
import { createCollection, addItem, getItem, removeItem }
from '@augmentcode/themis/utils/collections/collection-utils';
type User = { id: string; name: string; online: boolean };
let users = createCollection<User, 'id'>('id');
users = addItem(users, { id: 'u1', name: 'Alice', online: true });
users = addItem(users, { id: 'u2', name: 'Bob', online: false });
getItem(users, 'u1'); // { id: 'u1', name: 'Alice', online: true } — O(1)
getItem(users, 'u2'); // { id: 'u2', name: 'Bob', online: false }
users = removeItem(users, 'u1');
getItem(users, 'u1'); // undefinedArchitecture as lint rules
40+ custom ESLint rules encode the architecture so violations are caught before review. Import one composed domain config — svelte, react, streaming, or store — per app path.
// eslint.config.js
import { svelte } from '@augmentcode/themis/eslint-plugins';
export default svelte;
// Enables: no inline selectors in sagas, unique action types,
// one selectors file per slice, no wildcard saga takes,
// typed-redux-saga yield* style, channel cleanup guards, and more.Shorter prompts, same results
When the AI already knows where selectors live, how sagas are structured, and which utilities to reach for, you stop writing instructions about how to code and start writing instructions about what to build.
Add a selector that returns the current conversation ID. Create it in src/store/selectors/conversations.ts using reselect's createSelector. Import it in src/components/ChatHeader.tsx via useSelector. Make sure it's memoized so it doesn't cause unnecessary re-renders. Also check if there's a types file that needs updating.
Add a selector for the current conversation ID to the conversations slice.
When the user opens a conversation, fetch its messages from the API. Put the fetch logic in src/hooks/useConversation.ts using useEffect. Add loading and error state with useState. Cancel the request on unmount with AbortController. Lift the result to the parent via a callback prop.
Add a saga that calls the messages API when loadConversation is dispatched.
Store messages so they can be looked up by ID in O(1). Use an object keyed by message ID for the map, plus a separate array to preserve insertion order. Make sure both stay in sync on add and remove. Add a selector that returns them as an ordered array.
Add a messages collection to the messages slice.
If the API call fails, retry it up to 3 times with exponential backoff. Keep track of the attempt count. Cancel all retries if a global timeout of 30 seconds is exceeded. Dispatch a failure action and surface the outcome to the UI.
Wrap the API call in retryWithTimeout with maxRetries: 3 and timeoutMs: 30_000.
The AI skills bundled with the package teach the agent the full convention set — file placement, naming, selector ownership, saga patterns, collection utilities — so you never have to repeat it in a prompt.
Architecture that holds across 1000 iterations
Without enforced conventions, every AI-assisted change is a chance to introduce a new pattern. After dozens of iterations the codebase becomes a patchwork — selectors in sagas, API calls in components, duplicate action types, missing cleanup. Themis makes these violations impossible to commit.
Without enforced conventions
- ✕Selectors declared inline inside saga files
- ✕Duplicate action type strings registered silently
- ✕API calls scattered across hooks, sagas, and components
- ✕Multiple patterns for the same problem after each AI edit
- ✕No lint safety net — violations land in production
With Themis ESLint rules
- ✓Selector placement enforced: one selectors file per slice
- ✓Duplicate action types caught before commit
- ✓Side effects belong in sagas — component imports blocked
- ✓Every AI-written change follows the same structural rules
- ✓Violations surface as lint errors, not production bugs
Example — what a rule violation looks like
src/slices/messages/sagas/messages-saga.ts:12
src/slices/messages/sagas/messages-saga.ts:14
src/slices/messages/messages-slice.ts:44
The ESLint rules run in CI and in your editor. An AI agent that writes a violation sees the error immediately and corrects it — without you having to review the structural choice at all.
A feature pipeline, not a fragile codebase
Most client-side apps hit a ceiling: only the engineers who understand the state layer can safely ship features. Themis removes that ceiling. When architecture is enforced rather than remembered, anyone who follows the rules can contribute — and the rules are checked automatically.
Architect
Sets the structure
Defines slices, selectors, sagas, and collection shapes once. Configures ESLint domain rules. Writes the AI skills bundle. After this, the architecture is self-enforcing.
Engineer
Ships features
Adds new slices, actions, selectors, and sagas by following the pattern. Does not need to reason about where things go — the conventions are already decided and lint-checked.
AI agent
Scales delivery
Reads the skills bundle, generates conforming code, and self-corrects on lint violations. Contributes at the same structural quality bar as a human engineer, without supervision of every file placement.
The result
Grounded in CS theory
Themis's two core mechanisms — saga-based side effect management and proxy-tracked memoized selectors — each trace back to a formal academic result. The library applies these ideas directly to client-side state management.
The problem
A long-lived database transaction holds locks for its entire duration — seconds or minutes instead of milliseconds. This serialises all concurrent work behind it. The paper shows that most real workflows do not actually require a single monolithic lock: they are sequences of smaller steps where partial failure can be handled by compensation rather than full rollback.
The idea
A saga is a sequence T₁ → T₂ → … → Tₙ of individually committed sub-transactions. Each Tᵢ has a compensating transaction Cᵢ that semantically undoes its effect. If the saga fails at step k, it runs Cₖ₋₁, …, C₁ — backward recovery. Alternatively it can retry from the failure point — forward recovery. Either way, the system avoids half-finished state without holding a single global lock.
In Themis
Each yield* effect in a redux-saga generator is a sub-transaction: it commits immediately (puts an action, calls an API, etc.). On failure, dispatching a .failure action is the compensating transaction — it returns state to a consistent form. takeLatest and takeEvery orchestrate concurrent saga instances so multiple sequences interleave without interfering. The saga manager's exponential-backoff restart is forward recovery: when a saga crashes, restart it from the beginning rather than leaving the application in a degraded state.
The problem
When an input changes, a traditional program recomputes everything from scratch — O(n) work even if only a single value changed. For interactive or real-time systems, this makes the cost of an incremental update proportional to the full computation, not to the size of what actually changed.
The idea
During execution, the runtime records a computation trace — which mutable values (changeables) were read, in what order, and what they produced. When a changeable changes, the system propagates the change through the trace, re-executing only the portions that read that value. Unaffected branches are reused via memoization. The work done is proportional to the size of the change in output, not the full computation.
In Themis
store.createSelector(fn) runs the selector callback through a JavaScript Proxy that intercepts every property access on the state object — recording exactly which paths were read. This is the computation trace. When Redux emits a new state, Themis compares the new values at those recorded paths to the previous values. Only selectors whose recorded paths changed are re-run — this is change propagation through the dependency graph. Selectors that read paths that did not change return their memoized result immediately, at O(1) cost. The per-frame coalescing is a practical extension: instead of propagating every Redux dispatch immediately, updates are batched at up to 64 FPS, matching human perception rather than raw store mutation rate.
Get started in minutes
Install the package, pick your Store variant, wire your reducers, and start your sagas. The built-in manager handles the rest.
npm install @augmentcode/themis