Saga-first optimistic state management for Redux. Components dispatch intent, sagas orchestrate the lifecycle, optimistic state is derived at read-time by replaying transitions — like
git rebase.
npm install @lostsolution/optimistron
# requires: redux ^5, @reduxjs/toolkit ^2, redux-saga ^1| Import | Includes | Requires saga? |
|---|---|---|
@lostsolution/optimistron |
Core + saga effects | Yes |
@lostsolution/optimistron/core |
Core only (reducers, actions, selectors) | No |
@lostsolution/optimistron/saga |
Saga effects only | Yes |
Use /core for thunks, listener middleware, or manual orchestration — no saga dependency.
import { configureStore, createAction, createSelector } from '@reduxjs/toolkit';
import createSagaMiddleware from 'redux-saga';
import {
optimistron, createCrudTransitions, recordState,
watchTransition, retryFailed,
} from '@lostsolution/optimistron';
// 1. Define your entity
type Todo = { id: string; value: string; done: boolean; revision: number };
// 2. Create CRUD transition actions (golden-path modes built in)
const todo = createCrudTransitions<Todo>('todos', 'id');
// 3. Create the optimistic reducer
const { reducer: todos, selectors } = optimistron(
'todos',
{} as Record<string, Todo>,
recordState<Todo>({
key: 'id',
version: (t) => t.revision,
eq: (a, b) => a.done === b.done && a.value === b.value,
}),
{ create: todo.create, update: todo.update, remove: todo.remove },
);
// 4. Wire up the store
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({
reducer: { todos },
middleware: (getDefault) => getDefault().concat(sagaMiddleware),
});
// 5. Sagas — one line per transition
function* rootSaga() {
yield watchTransition(todo.create, api.createTodo, {
amend: (payload, saved) => ({ ...payload, id: saved.id }),
});
yield watchTransition(todo.update, api.updateTodo);
yield watchTransition(todo.remove, api.deleteTodo);
// stash-on-fail is automatic for REVERTIBLE transitions (removes)
const retryAll = createAction('todos::retryAll');
yield retryFailed(retryAll, (state) => selectors.selectFailures(state.todos));
}
sagaMiddleware.run(rootSaga);
// 6. Select optimistic state
const selectTodos = createSelector(
(state: RootState) => state.todos,
selectors.selectOptimistic((todos) => Object.values(todos.committed)),
);
// 7. Components only dispatch intent
dispatch(todo.create.stage(item)); // shows immediately
dispatch(todo.update.stage(edited)); // saga handles commit/failThink of each optimistron() reducer as a git branch:
| Git | Optimistron |
|---|---|
| Branch tip | Committed state — only COMMIT advances it |
| Staged commits | Transitions — pending changes on top of committed state |
git rebase |
selectOptimistic — replays transitions at read-time |
| Merge conflict | Sanitization — detects no-ops and conflicts after every mutation |
STAGE, AMEND, FAIL, STASH never touch reducer state — they only modify the transitions list. There are no separate isLoading / error / isOptimistic flags — a pending transition is the loading state, and a failed transition carries the error.
Returns a takeEvery effect. Watches for stage actions, calls the effect, then commits or fails based on the TransitionMode on the transition meta.
yield watchTransition(todo.create, api.createTodo, {
amend: (payload, result) => ({ ...payload, id: result.id }),
});
yield watchTransition(todo.update, api.updateTodo);
yield watchTransition(todo.remove, api.deleteTodo);effect—(payload, action) => Promise<R>. Wrapped incall()internally.options.amend—(payload, result) => P. Pure transform applied before commit.- fail vs stash — auto-detected from
TransitionModeon the transition meta.
The inner worker generator, exposed for custom watcher patterns:
yield takeLatest(todo.update.stage.match, handleTransition(todo.update, api.updateTodo));Returns a takeLeading effect. Re-dispatches all failed transitions when the trigger fires.
const retryAll = createAction('retryAll');
yield retryFailed(retryAll, (state: RootState) => [
...todosSelectors.selectFailures(state.todos),
...projectsSelectors.selectFailures(state.projects),
]);- One ID, one entity — each transition ID maps to exactly one entity
- One at a time — don't stage a new transition while one is already pending for the same ID
- One operation per transition — a single create, update, or delete
Declared per action type — controls what happens on re-stage and failure:
| Mode | On re-stage | On fail | Typical use |
|---|---|---|---|
DEFAULT |
Overwrite | Flag as failed | Edits |
DISPOSABLE |
Overwrite | Drop transition | Creates |
REVERTIBLE |
Store trailing | Revert to previous | Deletes |
Four built-in handlers for common state shapes:
| Handler | State shape | Usage |
|---|---|---|
recordState<T> |
Record<string, T> |
Flat key-value map |
nestedRecordState<T>() |
Record<string, Record<..., T>> |
Multi-level grouping |
singularState<T> |
T | null |
Singletons (profile, settings) |
listState<T> |
T[] |
Ordered list |
Custom shapes can implement the StateHandler interface directly.
All selectors are returned from optimistron() on the selectors object.
selectors.selectOptimistic(selector)(state.todos); // derived optimistic view
selectors.selectIsOptimistic(id)(state.todos); // pending?
selectors.selectIsFailed(id)(state.todos); // failed?
selectors.selectIsConflicting(id)(state.todos); // stale conflict?
selectors.selectFailures(state.todos); // all failed transitions
selectors.selectFailure(id)(state.todos); // specific failed transition
selectors.selectConflict(id)(state.todos); // specific conflictEntities need a monotonically increasing version — revision, updatedAt, a sequence number:
recordState<Todo>({
key: 'id',
version: (item) => item.revision,
eq: (a, b) => a.done === b.done && a.value === b.value,
});Without versioning, conflict detection degrades to content equality only.
Thunks
import { createCrudTransitions, recordState } from '@lostsolution/optimistron/core';
const createTodoThunk = (todo: Todo): Thunk => async (dispatch) => {
dispatch(todoActions.create.stage(todo));
try {
const saved = await api.create(todo);
dispatch(todoActions.create.amend(todo.id, saved));
dispatch(todoActions.create.commit(todo.id));
} catch (e) {
dispatch(todoActions.create.fail(todo.id, e));
}
};Component-level async
import { createCrudTransitions, recordState } from '@lostsolution/optimistron/core';
const handleCreate = async (todo: Todo) => {
dispatch(todoActions.create.stage(todo));
try {
const saved = await api.create(todo);
dispatch(todoActions.create.amend(todo.id, saved));
dispatch(todoActions.create.commit(todo.id));
} catch (e) {
dispatch(todoActions.create.fail(todo.id, e));
}
};bun test # tests with coverage (threshold 90%)
bun run build:esm # build to lib/See usecases/ for working examples with sagas, thunks, and basic async.
For internals and design decisions, see ARCHITECTURE.md.