How TREK's Undo Feature Tracks and Reverses User Actions in the Trip Planner
TREK implements trip planner undo functionality by maintaining a LIFO history stack inside the custom usePlannerHistory React hook, where each entry stores a descriptive label and an executable callback that reverses the specific user action.
TREK is an open-source trip planning application that requires robust state management for complex user interactions like adding places, reordering itineraries, and modifying day assignments. Rather than implementing a centralized command pattern, the codebase uses a lightweight, decoupled approach to track and reverse user actions through a custom React hook that maintains an in-memory stack of reversible operations.
Core Architecture of the Undo System
The undo mechanism centers on a custom hook called usePlannerHistory, defined in client/src/hooks/usePlannerHistory.ts. This hook manages a mutable history stack using React refs to avoid unnecessary re-renders while providing imperative control over the trip planner's reversible actions.
History Storage with UndoEntry
At the heart of the system lies the UndoEntry interface, which decouples the action description from its reversal logic:
export interface UndoEntry {
label: string
undo: () => Promise<void> | void
}
The stack persists in a useRef array, ensuring that pushing new entries does not trigger component re-renders until explicitly requested.
Pushing Actions to the Stack
When users perform CRUD operations in the trip planner, components call the pushUndo method with a human-readable label and an async undo function:
const pushUndo = (label: string, undoFn: () => Promise<void> | void) => {
historyRef.current = [{ label, undo: undoFn }, ...historyRef.current]
.slice(0, maxEntries)
forceUpdate()
}
This function prepends entries to maintain LIFO (Last-In-First-Out) order, enforces a default maximum of 30 entries to prevent memory bloat, and triggers a forced update via a dummy useReducer to refresh the UI state (e.g., enabling the Undo button).
Executing Undo Operations
The undo function pops the most recent entry, updates the stack, and executes the stored callback:
const undo = async () => {
if (historyRef.current.length === 0) return
const [first, ...rest] = historyRef.current
historyRef.current = rest
forceUpdate()
try { await first.undo() } catch (e) { console.error('Undo failed:', e) }
}
Error handling ensures that failed undo operations log to the console without crashing the UI, maintaining application stability even when network requests or state reversals fail.
Integration in the Trip Planner
The useTripPlanner hook in client/src/pages/tripPlanner/useTripPlanner.ts integrates the history system by pushing undo entries for every mutable operation.
Adding and Deleting Places
When adding a place, the system pushes an entry that deletes the newly created record:
pushUndo(t('undo.addPlace'), async () => {
await assignmentsApi.deletePlace(placeId)
// additional state clean-up …
})
Conversely, deleting a place stores the previous state for restoration:
pushUndo(t('undo.deletePlace'), async () => {
await assignmentsApi.createPlace(previousPlaceData)
// restore assignment if any …
})
Reordering Itineraries
For drag-and-drop reordering operations, the hook captures the original sequence:
pushUndo(t('undo.reorder'), async () => {
await assignmentsApi.reorderPlaces(originalOrder)
})
UI State and Feedback
The hook exposes canUndo and lastActionLabel to drive the interface. The trip planner component uses these to conditionally render the undo button and display tooltips showing which action will be reversed.
When users trigger undo, the handler executes the reversal and displays a confirmation toast:
const handleUndo = useCallback(async () => {
const label = lastActionLabel
await undo()
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
Testing the Undo Stack
Unit tests in client/tests/unit/hooks/usePlannerHistory.test.ts verify LIFO behavior, empty-stack safety, and proper callback execution. These tests ensure that the stack correctly consumes entries in reverse chronological order and handles edge cases like calling undo on an empty history.
Summary
- TREK's undo system uses a custom React hook called
usePlannerHistorythat maintains a mutable ref-based stack - Each entry stores an
UndoEntrywith a label and async undo function, enabling decoupled action reversal - The stack enforces a 30-entry limit and uses LIFO ordering to reverse the most recent action first
- Integration occurs in
useTripPlanner, which pushes inverse callbacks for add, delete, and reorder operations - The UI receives real-time feedback through
canUndoandlastActionLabelreturn values
Frequently Asked Questions
How does TREK prevent memory leaks in the undo history?
The usePlannerHistory hook truncates the stack to a configurable maxEntries limit (default 30) using slice(0, maxEntries) whenever new entries are pushed. This prevents unbounded growth during extended planning sessions while maintaining sufficient history for practical use.
What happens if an undo operation fails in TREK?
The undo function wraps the stored callback execution in a try-catch block that logs errors to the console without throwing. This ensures the UI remains responsive and the history stack stays synchronized even when network requests or state reversals fail.
Why does TREK use useRef instead of useState for the history stack?
Using useRef prevents React re-renders on every history push, improving performance during rapid user actions. The hook only triggers updates via a dummy useReducer when necessary to refresh UI elements like the undo button's disabled state or tooltip text.
Which file contains the translation strings for undo labels?
Internationalized undo labels reside in shared/src/i18n/zh/undo.ts and related i18n files, allowing the useTripPlanner hook to pass translated strings like "Add place" to the pushUndo function for display in tooltips and toast notifications.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →