Optimistic state management for Redux. No state copies, no checkpoints — optimistic state is derived at the selector level, like
git rebase.
Most optimistic-update libraries snapshot your entire state tree for every in-flight operation. Optimistron doesn't. It tracks lightweight transitions (stage, amend, commit, fail) alongside your reducer state and replays them at read-time through selectOptimistic — right where reselect memoization already lives.
Good fit for:
- Offline-first — transitions queue up while disconnected, conflicts resolve on reconnect
- Async dispatch — thunks, sagas, listener middleware
- Large/normalized state — no per-operation snapshots
Already happy with RTK Query's built-in optimistic updates? You probably don't need this.
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.
No isLoading / error / isOptimistic flags. A pending transition is loading. A failed one is the error. One source of truth.
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, selectOptimistic } = 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,
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');You can implement the StateHandler interface for any shape — the built-ins are just the common cases.
The 4th argument to optimistron() supports three modes:
Auto-wired — zero boilerplate, handler routes payloads:
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 |
const selectTodos = createSelector(
(state: RootState) => state.todos,
selectOptimistic((todos) => Object.values(todos.state)),
);import { selectIsOptimistic, selectIsFailed, selectIsConflicting } from '@lostsolution/optimistron';
selectIsOptimistic(id)(state.todos); // pending?
selectIsFailed(id)(state.todos); // failed?
selectIsConflicting(id)(state.todos); // stale conflict?import { selectAllFailedTransitions } from '@lostsolution/optimistron';
selectAllFailedTransitions(state.todos, state.projects, state.activity);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.