Optimistic state management for Redux. Optimistic state is derived at the selector level by replaying transitions on top of committed state, similar to
git rebase.
Optimistron tracks lightweight transitions (stage, amend, commit, fail) alongside your reducer state and replays them at read-time through selectOptimistic. No state snapshots or checkpoints are stored per operation.
Optimistron was designed around an event-driven saga architecture — components dispatch intent via stage, and sagas (or listener middleware) orchestrate the transition lifecycle. It can be used with thunks or direct component dispatches, but the separation between intent and orchestration is where it fits most naturally.
Designed for:
- Offline-first — transitions queue up while disconnected, conflicts resolve on reconnect
- Large/normalized state — state is derived, not copied
Think 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. The optimistic view updates because selectOptimistic re-derives on the next read.
There are no separate isLoading / error / isOptimistic flags — a pending transition represents the loading state, and a failed transition carries the error.
npm install @lostsolution/optimistron
# ⚠️ not published yetimport { configureStore, createSelector } from '@reduxjs/toolkit';
import { optimistron, createTransitions, crudPrepare, recordState, TransitionMode } from '@lostsolution/optimistron';
// 1. Define your entity
type Todo = { id: string; value: string; done: boolean; revision: number };
// 2. Create CRUD prepare functions (couples transitionId === entityId)
const crud = crudPrepare<Todo>('id');
// 3. Create transition action creators
const createTodo = createTransitions('todos::add', TransitionMode.DISPOSABLE)(crud.create);
const editTodo = createTransitions('todos::edit')(crud.update); // DEFAULT mode
const deleteTodo = createTransitions('todos::delete', TransitionMode.REVERTIBLE)(crud.remove);
// 4. Create the optimistic reducer
const { reducer: todos, selectors } = optimistron(
'todos',
{} as Record<string, Todo>,
recordState<Todo>({
key: 'id',
compare: (a) => (b) => (a.revision === b.revision ? 0 : a.revision > b.revision ? 1 : -1),
eq: (a) => (b) => a.done === b.done && a.value === b.value,
}),
{ create: createTodo, update: editTodo, remove: deleteTodo },
);
// 5. Wire up the store
const store = configureStore({ reducer: { todos } });
// 6. Select optimistic state (memoize with createSelector)
const selectTodos = createSelector(
(state: RootState) => state.todos,
selectors.selectOptimistic((todos) => Object.values(todos.state)),
);
// 7. Dispatch transitions
dispatch(createTodo.stage(todo)); // optimistic — shows immediately
dispatch(createTodo.commit(todo.id)); // server confirmed — becomes committed state
dispatch(createTodo.fail(todo.id, error)); // server rejected — flagged as failed- 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
Entities need a monotonically increasing version — revision, updatedAt, a sequence number. This is how sanitization tells "newer" from "stale":
compare: (a) => (b) => 0 | 1 | -1; // version ordering (curried)
eq: (a) => (b) => boolean; // content equality at same version (curried)Without versioning, conflict detection degrades to content equality only.
Four built-in handlers for common state shapes. Each defines create, update, remove, and merge.
Record<string, T> indexed by a single key. The most common shape.
const handler = recordState<Todo>({ key: 'id', compare, eq });
const crud = crudPrepare<Todo>('id');Record<string, Record<string, ... T>> for multi-level grouping. Curried to fix T and infer the keys tuple. transitionId joins path IDs with /.
const handler = nestedRecordState<ProjectTodo>()({ keys: ['projectId', 'id'], compare, eq });
const crud = crudPrepare<ProjectTodo>()(['projectId', 'id']);T | null for singletons (user profile, settings).
const handler = singularState<Profile>({ compare, eq });T[] where insertion order matters.
const handler = listState<Todo>({ key: 'id', compare, eq });
const crud = crudPrepare<Todo>('id');Custom shapes can implement the StateHandler interface directly.
The 4th argument to optimistron() supports three modes:
Auto-wired — handler routes payloads by CRUD type:
optimistron('todos', initial, handler, {
create: createTodo,
update: editTodo,
remove: deleteTodo,
});Hybrid — auto-wire + fallback for custom actions:
optimistron('todos', initial, handler, {
create: createTodo,
update: editTodo,
remove: deleteTodo,
reducer: ({ getState }, action) => {
/* custom logic */
},
});Manual — full control via BoundStateHandler:
optimistron('todos', initial, handler, ({ getState, create, update, remove }, action) => {
if (createTodo.match(action)) return create(action.payload);
if (editTodo.match(action)) return update(action.payload);
if (deleteTodo.match(action)) return remove(action.payload);
return getState();
});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 |
All selectors are returned from optimistron() on the selectors object — there are no standalone selector exports.
const { selectors } = optimistron('todos', initial, handler, config);
const selectTodos = createSelector(
(state: RootState) => state.todos,
selectors.selectOptimistic((todos) => Object.values(todos.state)),
);const { selectors } = optimistron('todos', initial, handler, config);
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 in this slice
selectors.selectFailure(id)(state.todos); // failed transition for a specific entity
selectors.selectConflict(id)(state.todos); // conflicting transition for a specific entityCross-slice aggregation is a consumer concern. Compose per-slice selectors:
const selectAllFailed = createSelector(
(state: RootState) => state.todos,
(state: RootState) => state.projects,
(todos, projects) => [
...todosSelectors.selectFailures(todos),
...projectsSelectors.selectFailures(projects),
],
);bun test # tests with coverage (threshold 90%)
bun run build:esm # build to lib/See usecases/ for working examples with basic async, thunks, and sagas.
For internals, design decisions, and the full API reference, see ARCHITECTURE.md.