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 usePlannerHistory that maintains a mutable ref-based stack
  • Each entry stores an UndoEntry with 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 canUndo and lastActionLabel return 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →