Skip to content

History (Undo/Redo)

Reacton provides built-in time-travel capabilities. Enable history on any writable reacton to get undo, redo, and jump-to-any-point navigation. Combined with the ActionLog, you get a full audit trail of every state mutation for debugging and analytics.

Enabling History

Call store.enableHistory() to create a History<T> controller for any writable reacton:

dart
final counterReacton = reacton(0, name: 'counter');

final history = store.enableHistory(counterReacton, maxHistory: 50);

store.set(counterReacton, 1);
store.set(counterReacton, 2);
store.set(counterReacton, 3);

history.undo(); // counter = 2
history.undo(); // counter = 1
history.redo(); // counter = 2
history.jumpTo(0); // counter = 0 (initial value)

Signature

dart
History<T> enableHistory<T>(
  WritableReacton<T> reacton, {
  int maxHistory = 100,
});
ParameterTypeDefaultDescription
reactonWritableReacton<T>--The reacton to track.
maxHistoryint100Maximum number of history entries. Oldest entries are evicted when the limit is reached.

History<T> API

Properties

PropertyTypeDescription
entriesList<HistoryEntry<T>>Unmodifiable list of all history entries.
currentIndexintThe current position in history (0-indexed).
currentValueTThe value at the current history position.
canUndobooltrue if there is a previous entry to undo to.
canRedobooltrue if there is a next entry to redo to.
lengthintTotal number of history entries.

Methods

MethodReturnsDescription
undo()voidGo back one step in history. No-op if canUndo is false.
redo()voidGo forward one step in history. No-op if canRedo is false.
jumpTo(index)voidJump to a specific index in history. Asserts that the index is in range.
clear()voidClear all history and start fresh with the current value.
dispose()voidUnsubscribe from the reacton and free resources.

HistoryEntry<T>

Each entry in the history log records a value and when it was set.

dart
class HistoryEntry<T> {
  final T value;
  final DateTime timestamp;
  final String? label;
}
FieldTypeDescription
valueTThe value at this point in history.
timestampDateTimeWhen this entry was recorded.
labelString?Optional label describing what caused this change.

How History Tracks Changes

History records every value change to the tracked reacton. When you undo or redo, the reacton's value is set directly by the history controller, and these undo/redo operations are not recorded as new entries (preventing infinite loops).

Forking Behavior

When you undo to a previous state and then make a new change, all "future" entries (those after the current index) are discarded. This is the same behavior as text editor undo:

Initial state: [0]
                ^

Set to 1: [0, 1]
              ^

Set to 2: [0, 1, 2]
                 ^

Undo:     [0, 1, 2]
              ^           (current = 1)

Set to 5: [0, 1, 5]      (entry "2" is discarded)
                 ^

Max History Enforcement

When the number of entries exceeds maxHistory, the oldest entries are removed from the front:

maxHistory = 3

[a, b, c]     (full)
Set d:
[b, c, d]     (oldest "a" evicted)

Widget Integration

dart
class DrawingPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final store = context.store;
    // Assume history was created during app initialization
    // final history = store.enableHistory(canvasReacton);

    return Scaffold(
      appBar: AppBar(
        actions: [
          IconButton(
            onPressed: history.canUndo ? () => history.undo() : null,
            icon: Icon(Icons.undo),
          ),
          IconButton(
            onPressed: history.canRedo ? () => history.redo() : null,
            icon: Icon(Icons.redo),
          ),
        ],
      ),
      body: DrawingCanvas(),
    );
  }
}

ActionLog

The ActionLog class provides a complete audit trail of all state mutations across the entire store. Unlike History<T> (which tracks a single reacton), ActionLog records changes to all reactons for debugging, analytics, and DevTools integration.

Creating an ActionLog

dart
final log = ActionLog(maxRecords: 1000);
ParameterTypeDefaultDescription
maxRecordsint1000Maximum number of records. Oldest are evicted when full.

Recording Actions

dart
log.record(ActionRecord(
  reactonRef: counterReacton.ref,
  oldValue: 0,
  newValue: 1,
  timestamp: DateTime.now(),
  stackTrace: StackTrace.current, // optional, for debugging
));

ActionRecord

FieldTypeDescription
reactonRefReactonRefThe reacton that was modified.
oldValuedynamicThe previous value.
newValuedynamicThe new value.
timestampDateTimeWhen the action occurred.
stackTraceStackTrace?Stack trace at the point of mutation (for debugging).

Querying the Log

MethodReturnsDescription
recordsList<ActionRecord>All recorded actions (unmodifiable).
forReacton(ref)List<ActionRecord>All records for a specific reacton.
inRange(from, to)List<ActionRecord>Records within a time range.
lengthintTotal number of recorded actions.
isEnabledboolWhether logging is currently enabled.

Controlling the Log

MethodDescription
enable()Enable logging (on by default).
disable()Disable logging. New records are silently dropped.
clear()Clear all records.

Example: Debugging State Changes

dart
final log = ActionLog();

// After some mutations...
store.set(counterReacton, 1);
store.set(counterReacton, 2);
store.set(nameReacton, 'Alice');

// Query the log
final counterChanges = log.forReacton(counterReacton.ref);
print('Counter changed ${counterChanges.length} times');

for (final record in counterChanges) {
  print('  ${record.oldValue} -> ${record.newValue} at ${record.timestamp}');
}

// Query by time range
final recentChanges = log.inRange(
  DateTime.now().subtract(Duration(minutes: 5)),
  DateTime.now(),
);
print('${recentChanges.length} changes in the last 5 minutes');

Complete Example: Form with Undo

dart
// Form state
final formDataReacton = reacton(
  FormData(name: '', email: '', bio: ''),
  name: 'formData',
);

// Enable history
late final History<FormData> formHistory;

void initFormHistory(ReactonStore store) {
  formHistory = store.enableHistory(formDataReacton, maxHistory: 50);
}

// Widget
class EditProfilePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final formData = context.watch(formDataReacton);

    return Scaffold(
      appBar: AppBar(
        title: Text('Edit Profile'),
        actions: [
          IconButton(
            icon: Icon(Icons.undo),
            onPressed: formHistory.canUndo ? () => formHistory.undo() : null,
            tooltip: 'Undo',
          ),
          IconButton(
            icon: Icon(Icons.redo),
            onPressed: formHistory.canRedo ? () => formHistory.redo() : null,
            tooltip: 'Redo',
          ),
          Text('${formHistory.currentIndex + 1}/${formHistory.length}'),
        ],
      ),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              controller: TextEditingController(text: formData.name),
              decoration: InputDecoration(labelText: 'Name'),
              onChanged: (value) => context.set(
                formDataReacton,
                formData.copyWith(name: value),
              ),
            ),
            TextField(
              controller: TextEditingController(text: formData.email),
              decoration: InputDecoration(labelText: 'Email'),
              onChanged: (value) => context.set(
                formDataReacton,
                formData.copyWith(email: value),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

What's Next

Released under the MIT License.