Undo and Redo
Observant provides built-in undo and redo functionality, allowing you to track changes to your data and revert them if needed. This page explains how the undo/redo system works in Observant.
Overview
The undo/redo system in Observant is field-based, meaning you can undo changes to individual fields independently. This gives you fine-grained control over which changes to revert.
Key features of the undo/redo system:
- Per-field undo/redo
- Configurable undo stack size
- Debounce support for rapid changes
- Integration with validation and dirty tracking
Enabling Undo
To enable undo functionality, you can either:
- Enable it globally when creating the proxy
- Enable it for specific fields after creating the proxy
Global Undo
To enable undo globally, set undo=True
when creating the proxy:
from dataclasses import dataclass
from observant import ObservableProxy
@dataclass
class User:
name: str
age: int
user = User(name="Alice", age=30)
proxy = ObservableProxy(user, undo=True) # Enable undo for all fields
Per-Field Undo
To enable undo for specific fields, use the set_undo_config
method:
from dataclasses import dataclass
from observant import ObservableProxy
@dataclass
class User:
name: str
age: int
email: str
user = User(name="Alice", age=30, email="alice@example.com")
proxy = ObservableProxy(user) # Undo disabled by default
# Enable undo for specific fields
proxy.set_undo_config("name", enabled=True)
proxy.set_undo_config("age", enabled=True)
# email field will not have undo enabled
Undo Configuration
The undo system can be configured with several options:
enabled
: Whether undo is enabled for a fieldundo_max
: Maximum number of undo steps to storeundo_debounce_ms
: Time in milliseconds to wait before recording a new undo step
undo_max
The undo_max
option limits the number of undo steps that are stored for a field. This prevents the undo stack from growing too large and consuming too much memory.
undo_debounce_ms
The undo_debounce_ms
option adds debounce behavior to the undo system. If multiple changes are made to a field within the debounce time, only the last change is recorded as an undo step.
This is useful for fields that change rapidly, such as text fields during typing:
# Debounce undo steps by 500ms
proxy.set_undo_config("name", enabled=True, undo_debounce_ms=500)
# These rapid changes will be combined into a single undo step
name_obs = proxy.observable(str, "name")
name_obs.set("A")
name_obs.set("Al")
name_obs.set("Ali")
name_obs.set("Alic")
name_obs.set("Alice")
# Only one undo step is created
proxy.undo("name") # Reverts directly to the original value
Undo/Redo API
Once undo is enabled, you can use the following methods to undo and redo changes:
undo(field)
: Undo the last change to a fieldredo(field)
: Redo the last undone change to a fieldcan_undo(field)
: Check if there are any undo steps available for a fieldcan_redo(field)
: Check if there are any redo steps available for a field
Basic Undo/Redo
from dataclasses import dataclass
from observant import ObservableProxy
@dataclass
class User:
name: str
age: int
user = User(name="Alice", age=30)
proxy = ObservableProxy(user, undo=True)
# Make some changes
name_obs = proxy.observable(str, "name")
age_obs = proxy.observable(int, "age")
name_obs.set("Bob")
age_obs.set(25)
# Undo changes
proxy.undo("name") # Reverts name to "Alice"
proxy.undo("age") # Reverts age to 30
# Redo changes
proxy.redo("name") # Sets name back to "Bob"
proxy.redo("age") # Sets age back to 25
Checking Undo/Redo Availability
You can check if undo or redo is available for a field using the can_undo
and can_redo
methods:
# Check if undo is available
if proxy.can_undo("name").get():
proxy.undo("name")
# Check if redo is available
if proxy.can_redo("name").get():
proxy.redo("name")
These methods return observables, so you can also listen for changes:
# Listen for changes to undo availability
proxy.can_undo("name").on_change(lambda can_undo:
print(f"Undo available for name: {can_undo}"))
# Listen for changes to redo availability
proxy.can_redo("name").on_change(lambda can_redo:
print(f"Redo available for name: {can_redo}"))
Per-Field vs Global Undo
Important: Observant's undo system is field-based, not transaction-based. Each field has its own independent undo stack. There is no concept of a "global undo" that reverts multiple fields at once as a single transaction.
Observant's field-based undo system allows you to undo changes to one field without affecting others:
# Make changes to multiple fields
name_obs = proxy.observable(str, "name")
age_obs = proxy.observable(int, "age")
name_obs.set("Bob")
age_obs.set(25)
# Undo only the name change
proxy.undo("name") # Reverts name to "Alice"
# Age remains changed
print(age_obs.get()) # 25
This field-based approach gives you more control over which changes to undo, but it also means you need to undo each field separately if you want to undo all changes.
Batch Undo Helper
If you need to undo all changes at once, you can create a helper function:
def undo_all(proxy, fields):
"""Undo all fields that have undo steps available."""
for field in fields:
if proxy.can_undo(field).get():
proxy.undo(field)
# Usage:
undo_all(proxy, ["name", "age", "email"])
You can extend this pattern for more complex undo scenarios:
def undo_to_clean_state(proxy, fields):
"""Undo all fields until none are dirty."""
while proxy.is_dirty().get():
for field in fields:
if proxy.is_field_dirty(field).get() and proxy.can_undo(field).get():
proxy.undo(field)
# Break if we can't undo any more dirty fields
if all(not proxy.can_undo(field).get() for field in fields if proxy.is_field_dirty(field).get()):
break
Limitations and Gotchas
Sync and Undo
The sync
and undo
options can be used together, but this can lead to unexpected behavior. When sync=True
, changes are immediately applied to the underlying model, which means that undo operations will not affect the model until you call save_to
.
user = User(name="Alice", age=30)
proxy = ObservableProxy(user, sync=True, undo=True)
# Make a change
name_obs = proxy.observable(str, "name")
name_obs.set("Bob")
print(user.name) # "Bob" (sync=True applies changes immediately)
# Undo the change
proxy.undo("name")
print(name_obs.get()) # "Alice"
print(user.name) # Still "Bob" until save_to is called
# Save changes back to the model
proxy.save_to(user)
print(user.name) # Now "Alice"
For this reason, it's generally recommended to use sync=False
when using undo functionality.
Collection Fields
For list and dictionary fields, undo/redo works on the entire collection, not individual elements. This means that if you make multiple changes to a collection, undoing will revert all of those changes at once.
from dataclasses import dataclass
from observant import ObservableProxy
@dataclass
class TodoList:
tasks: list[str]
todo_list = TodoList(tasks=["Buy milk"])
proxy = ObservableProxy(todo_list, undo=True)
# Get the observable list
tasks = proxy.observable_list(str, "tasks")
# Make multiple changes
tasks.append("Write docs")
tasks.append("Fix bugs")
tasks.remove("Buy milk")
# Undo all changes at once
proxy.undo("tasks") # Reverts to ["Buy milk"]
Computed Fields
Computed fields are not directly undoable, since their values are derived from other fields. However, when you undo changes to a field that a computed field depends on, the computed field will update accordingly.
from dataclasses import dataclass
from observant import ObservableProxy
@dataclass
class User:
first_name: str
last_name: str
user = User(first_name="Alice", last_name="Smith")
proxy = ObservableProxy(user, undo=True)
# Register a computed property
proxy.register_computed(
"full_name",
lambda: f"{proxy.observable(str, 'first_name').get()} {proxy.observable(str, 'last_name').get()}",
dependencies=["first_name", "last_name"]
)
# Make a change
first_name_obs = proxy.observable(str, "first_name")
first_name_obs.set("Bob")
print(proxy.computed(str, "full_name").get()) # "Bob Smith"
# Undo the change
proxy.undo("first_name")
print(proxy.computed(str, "full_name").get()) # "Alice Smith"
Validation and Undo
When you undo a change, validation is automatically re-run for the affected field. This means that if you undo a change that makes a field invalid, the validation errors will be updated accordingly.
from dataclasses import dataclass
from observant import ObservableProxy
@dataclass
class User:
username: str
user = User(username="")
proxy = ObservableProxy(user, undo=True)
# Add a validator
proxy.add_validator("username", lambda v: "Username required" if not v else None)
# Set a valid value
username_obs = proxy.observable(str, "username")
username_obs.set("alice")
print(proxy.is_valid().get()) # True
# Undo the change
proxy.undo("username")
print(proxy.is_valid().get()) # False
print(proxy.validation_for("username").get()) # ["Username required"]
Next Steps
Now that you understand how undo and redo work in Observant, you might want to explore:
- Computed Properties: Create properties that depend on other fields
- Dirty Tracking: Track unsaved changes
- Sync vs Non-Sync: Understand immediate vs. deferred updates