v0.0.3 · MIT · @augmentcode/themis

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/themis

redux, 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.

VariantCallReturnsComponent reads as
Store (Svelte)selectCount()Readable<number>$count
ReactStoreselectCount()ReadonlySignal<number>signal.value
ReactStoreselectCount.useValue()numbercount (hook)
StreamingStoreselectCount()Observable<number>.observe(cb)
AnyselectCount.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');      // undefined

Architecture 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.

Task: Add a selector for the current conversation ID
Without conventions42 words

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.

With Themis skills12 words

Add a selector for the current conversation ID to the conversations slice.

Task: Fetch data when a user opens a conversation
Without conventions44 words

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.

With Themis skills12 words

Add a saga that calls the messages API when loadConversation is dispatched.

Task: Store a list of messages by ID
Without conventions50 words

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.

With Themis skills8 words

Add a messages collection to the messages slice.

Task: Retry a failing API call up to 3 times
Without conventions43 words

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.

With Themis skills12 words

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

eslint·3 errors
errorSelector declared inside saga module. Move to messages-selectors.ts and import it. themis/no-inline-selector-in-saga
src/slices/messages/sagas/messages-saga.ts:12
errorInline lambda passed to yield* select(). Use selectCurrentMessage.effect() instead. themis/no-inline-saga-select
src/slices/messages/sagas/messages-saga.ts:14
errorAction type "messages/load" is already registered in messages-slice.ts:18. themis/duplicate-action-type
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

Feature velocity scales with contributors, not with state expertise
Architecture stays consistent whether the PR is from a human or an agent
New contributors onboard from the skills bundle, not from tribal knowledge
Senior engineer time goes to architecture decisions, not code review for placement
Each iteration reinforces the pattern — not erodes it
The codebase looks the same on day 1 and day 500

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.

SagasGarcia-Molina & Salem · ACM SIGMOD 1987paper →

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.

Self-Adjusting ComputationAcar · CMU PhD dissertation 2005paper →

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.

Read the docs →npm install @augmentcode/themis