.claude/skills/asyncredux-undo-redo/SKILL.md
Implement undo/redo functionality using state observers. Covers recording state history with stateObserver, creating a RecoverStateAction, implementing undo for the full state or partial state, and managing history limits.
npx skillsauth add marcglasberg/async_redux asyncredux-undo-redoInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
AsyncRedux simplifies undo/redo through a straightforward pattern: create a state observer to save states to a history list, and create actions to navigate through that history.
The StateObserver abstract class tracks state modifications. Implement its observe method to be notified of state changes:
abstract class StateObserver<St> {
void observe(
ReduxAction<St> action,
St stateIni,
St stateEnd,
Object? error,
int dispatchCount,
);
}
Parameters:
action - The dispatched action that triggered the state changestateIni - The state before the reducer applied changesstateEnd - The new state returned by the reducererror - Null if successful; contains the thrown error otherwisedispatchCount - Sequential dispatch numberTiming: The observer fires right after the reducer returns, before both the after() method and error-wrapping processes.
class UndoRedoObserver implements StateObserver<AppState> {
final List<AppState> _history = [];
int _currentIndex = -1;
final int maxHistorySize;
UndoRedoObserver({this.maxHistorySize = 50});
@override
void observe(
ReduxAction<AppState> action,
AppState stateIni,
AppState stateEnd,
Object? error,
int dispatchCount,
) {
// Skip if action had an error
if (error != null) return;
// Skip if state didn't change
if (stateIni == stateEnd) return;
// Skip undo/redo actions to prevent recursive history entries
if (action is UndoAction || action is RedoAction) return;
// When navigating backwards then performing new actions,
// clear the "future" history
if (_currentIndex < _history.length - 1) {
_history.removeRange(_currentIndex + 1, _history.length);
}
// Add the new state to history
_history.add(stateEnd);
_currentIndex = _history.length - 1;
// Enforce maximum history size by removing oldest entries
while (_history.length > maxHistorySize) {
_history.removeAt(0);
_currentIndex--;
}
}
/// Returns the previous state, or null if at the beginning
AppState? getPreviousState() {
if (_currentIndex > 0) {
_currentIndex--;
return _history[_currentIndex];
}
return null;
}
/// Returns the next state, or null if at the end
AppState? getNextState() {
if (_currentIndex < _history.length - 1) {
_currentIndex++;
return _history[_currentIndex];
}
return null;
}
bool get canUndo => _currentIndex > 0;
bool get canRedo => _currentIndex < _history.length - 1;
}
Pass the observer to stateObservers during store creation:
// Create the observer instance so actions can access it
final undoRedoObserver = UndoRedoObserver(maxHistorySize: 100);
var store = Store<AppState>(
initialState: AppState.initialState(),
stateObservers: [undoRedoObserver],
);
Create UndoAction and RedoAction that retrieve states from history:
class UndoAction extends ReduxAction<AppState> {
final UndoRedoObserver observer;
UndoAction(this.observer);
@override
AppState? reduce() {
return observer.getPreviousState();
}
}
class RedoAction extends ReduxAction<AppState> {
final UndoRedoObserver observer;
RedoAction(this.observer);
@override
AppState? reduce() {
return observer.getNextState();
}
}
Alternative: Access the observer through dependency injection using the environment pattern:
class UndoAction extends ReduxAction<AppState> {
@override
AppState? reduce() {
final observer = env.undoRedoObserver;
return observer.getPreviousState();
}
}
Dispatch undo/redo actions from widgets:
class UndoRedoButtons extends StatelessWidget {
final UndoRedoObserver observer;
const UndoRedoButtons({required this.observer});
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
icon: Icon(Icons.undo),
onPressed: observer.canUndo
? () => context.dispatch(UndoAction(observer))
: null,
),
IconButton(
icon: Icon(Icons.redo),
onPressed: observer.canRedo
? () => context.dispatch(RedoAction(observer))
: null,
),
],
);
}
}
The same approach works to undo/redo only part of the state. This is useful when you want to track changes to a specific slice of state independently.
class PartialUndoRedoObserver implements StateObserver<AppState> {
final List<DocumentState> _history = [];
int _currentIndex = -1;
final int maxHistorySize;
PartialUndoRedoObserver({this.maxHistorySize = 50});
@override
void observe(
ReduxAction<AppState> action,
AppState stateIni,
AppState stateEnd,
Object? error,
int dispatchCount,
) {
if (error != null) return;
if (action is UndoDocumentAction || action is RedoDocumentAction) return;
// Only track changes to the document portion of state
if (stateIni.document == stateEnd.document) return;
if (_currentIndex < _history.length - 1) {
_history.removeRange(_currentIndex + 1, _history.length);
}
_history.add(stateEnd.document);
_currentIndex = _history.length - 1;
while (_history.length > maxHistorySize) {
_history.removeAt(0);
_currentIndex--;
}
}
DocumentState? getPreviousDocument() {
if (_currentIndex > 0) {
_currentIndex--;
return _history[_currentIndex];
}
return null;
}
DocumentState? getNextDocument() {
if (_currentIndex < _history.length - 1) {
_currentIndex++;
return _history[_currentIndex];
}
return null;
}
}
class UndoDocumentAction extends ReduxAction<AppState> {
final PartialUndoRedoObserver observer;
UndoDocumentAction(this.observer);
@override
AppState? reduce() {
final previousDoc = observer.getPreviousDocument();
if (previousDoc == null) return null;
return state.copy(document: previousDoc);
}
}
Key considerations for history management:
// Example: Different limits for different use cases
final documentObserver = UndoRedoObserver(maxHistorySize: 100); // Heavy undo
final preferencesObserver = UndoRedoObserver(maxHistorySize: 10); // Light undo
// observer.dart
class UndoRedoObserver implements StateObserver<AppState> {
final List<AppState> _history = [];
int _currentIndex = -1;
final int maxHistorySize;
UndoRedoObserver({this.maxHistorySize = 50});
@override
void observe(
ReduxAction<AppState> action,
AppState stateIni,
AppState stateEnd,
Object? error,
int dispatchCount,
) {
if (error != null) return;
if (stateIni == stateEnd) return;
if (action is UndoAction || action is RedoAction) return;
if (_currentIndex < _history.length - 1) {
_history.removeRange(_currentIndex + 1, _history.length);
}
_history.add(stateEnd);
_currentIndex = _history.length - 1;
while (_history.length > maxHistorySize) {
_history.removeAt(0);
_currentIndex--;
}
}
AppState? getPreviousState() {
if (_currentIndex > 0) {
_currentIndex--;
return _history[_currentIndex];
}
return null;
}
AppState? getNextState() {
if (_currentIndex < _history.length - 1) {
_currentIndex++;
return _history[_currentIndex];
}
return null;
}
bool get canUndo => _currentIndex > 0;
bool get canRedo => _currentIndex < _history.length - 1;
void clear() {
_history.clear();
_currentIndex = -1;
}
}
// actions.dart
class UndoAction extends ReduxAction<AppState> {
@override
AppState? reduce() => env.undoRedoObserver.getPreviousState();
}
class RedoAction extends ReduxAction<AppState> {
@override
AppState? reduce() => env.undoRedoObserver.getNextState();
}
// main.dart
void main() {
final undoRedoObserver = UndoRedoObserver(maxHistorySize: 100);
final store = Store<AppState>(
initialState: AppState.initialState(),
stateObservers: [undoRedoObserver],
environment: Environment(undoRedoObserver: undoRedoObserver),
);
runApp(
StoreProvider<AppState>(
store: store,
child: MyApp(),
),
);
}
URLs from the documentation:
data-ai
Show loading states and handle action failures in widgets. Covers `isWaiting(ActionType)` for spinners, `isFailed(ActionType)` for error states, `exceptionFor(ActionType)` for error messages, and `clearExceptionFor()` to reset failure states.
data-ai
Use `waitCondition()` inside actions to pause execution until state meets criteria. Covers waiting for price thresholds, coordinating between actions, and implementing conditional workflows.
testing
Handle user-facing errors with UserException. Covers throwing UserException from actions, setting up UserExceptionDialog, customizing error dialogs with `onShowUserExceptionDialog`, and using UserExceptionAction for non-interrupting error display.
tools
Add the Throttle mixin to prevent actions from running too frequently. Covers setting the throttle duration in milliseconds, use cases like price refresh, and how freshness/staleness works.