Memento Pattern
Capture an object's state as a snapshot so it can be restored later without exposing internals.
The Problemβ
You're building a photo editor. Users apply filters: brightness, contrast, saturation, blur, sepia. They want to undo, but not step by step: they want to click any previous state and jump back to it instantly.
The naive approach:
let filterHistory: FilterState[] = [];
let currentIndex = 0;
function applyFilter(filter: string, value: number) {
canvas.apply(filter, value);
// Manual snapshot management everywhere
filterHistory = filterHistory.slice(0, currentIndex + 1);
filterHistory.push(captureState(canvas));
currentIndex++;
}
function jumpTo(index: number) {
currentIndex = index;
canvas.restore(filterHistory[index]); // hope the index is valid
}
History logic tangled with filter logic. No thumbnails, no metadata, no safety.
The Solutionβ
Separate history management from business logic. Each state change creates a snapshot automatically:
import { createHistory } from "@pithos/core/eidos/memento/memento";
interface PhotoState {
brightness: number;
contrast: number;
saturation: number;
blur: number;
sepia: number;
thumbnail: string; // base64 preview
}
const history = createHistory<PhotoState>({
brightness: 100, contrast: 100, saturation: 100, blur: 0, sepia: 0,
thumbnail: captureThumb(canvas),
});
function applyFilter(filter: keyof PhotoState, value: number) {
const current = history.current();
const next = { ...current, [filter]: value, thumbnail: captureThumb(canvas) };
history.push(next);
}
// Jump to any snapshot directly
const snapshots = history.history(); // all snapshots with timestamps
// Click snapshot #3 β undo until we reach it
History is automatic. Each snapshot includes a thumbnail for visual navigation. Unlike Command (which stores named operations like "increase brightness +10"), Memento stores the full visual state: what you see is what you restore.
Live Demoβ
A photo editor where you apply filters (brightness, contrast, saturation, blur, sepia). Each change creates a snapshot with a thumbnail. The History panel lets you jump to any state directly, not step by step. Unlike Command (named operations), Memento captures visual snapshots of the entire state.
- history.ts
- Usage
/**
* Memento pattern: photo history using createHistory.
*/
import { createHistory } from "@pithos/core/eidos/memento/memento";
import type { PhotoState } from "./types";
export function createPhotoHistory(initial: PhotoState) {
return createHistory<PhotoState>(initial);
}
import { useState } from "react";
import { Undo2, Redo2, Trash2, RotateCcw } from "lucide-react";
import { usePhotoEditor } from "@/hooks/usePhotoEditor";
import { ImagePreview } from "./ImagePreview";
import { FilterSliders } from "./FilterSliders";
import { HistoryPanel } from "./HistoryPanel";
export function PhotoEditor() {
const {
sourceRef, filters, snapshots, activeIndex,
canUndo, canRedo, imageLoaded, isFiltersDefault,
handleSliderChange, handleSliderCommit,
handleUndo, handleRedo, handleClear, handleJumpTo,
} = usePhotoEditor();
const [mobileTab, setMobileTab] = useState<"editor" | "history">("editor");
return (
<div className="h-screen flex flex-col bg-[#0d0d0d] text-white overflow-hidden select-none">
<canvas ref={sourceRef} className="hidden" />
{/* Mobile */}
<div className="sm:hidden flex flex-col h-full">
<MobileHeader canUndo={canUndo} canRedo={canRedo} onUndo={handleUndo} onRedo={handleRedo} />
<div className="flex-1 overflow-auto">
{mobileTab === "editor" ? (
<div className="p-2 space-y-2">
<ImagePreview sourceRef={sourceRef} filters={filters} loaded={imageLoaded} />
<FilterSliders filters={filters} onChange={handleSliderChange} onCommit={handleSliderCommit} compact />
<button
onClick={handleClear}
disabled={isFiltersDefault}
className="w-full py-2 rounded-lg bg-white/[0.04] border border-white/[0.06] text-zinc-500 text-[11px] hover:bg-white/[0.07] disabled:opacity-20 disabled:hover:bg-white/[0.04] transition-colors flex items-center justify-center gap-1.5"
>
<RotateCcw size={11} /> Reset
</button>
</div>
) : (
<HistoryPanel snapshots={snapshots} activeIndex={activeIndex} onJumpTo={(i) => { handleJumpTo(i); setMobileTab("editor"); }} onClear={handleClear} />
)}
</div>
<MobileTabBar tab={mobileTab} onTabChange={setMobileTab} snapshotCount={snapshots.length} />
</div>
{/* Desktop */}
<div className="hidden sm:flex h-full">
<div className="flex-1 flex flex-col min-w-0">
<div className="h-11 flex items-center justify-between px-4 border-b border-white/[0.06] bg-[#161616] flex-shrink-0">
<span className="text-[13px] font-medium text-zinc-300 tracking-wide">Photo Editor</span>
<div className="flex items-center gap-0.5">
<ToolbarButton icon={Undo2} onClick={handleUndo} disabled={!canUndo} title="Undo" />
<ToolbarButton icon={Redo2} onClick={handleRedo} disabled={!canRedo} title="Redo" />
<ToolbarButton icon={RotateCcw} onClick={handleClear} disabled={isFiltersDefault} title="Reset" />
</div>
</div>
<div className="flex-1 flex items-center justify-center p-6 bg-[#0d0d0d] min-h-0">
<ImagePreview sourceRef={sourceRef} filters={filters} loaded={imageLoaded} />
</div>
<div className="border-t border-white/[0.06] bg-[#161616] px-4 py-3 flex-shrink-0">
<FilterSliders filters={filters} onChange={handleSliderChange} onCommit={handleSliderCommit} />
</div>
</div>
<div className="w-[200px] flex flex-col border-l border-white/[0.06] bg-[#141414] flex-shrink-0">
<div className="h-11 flex items-center justify-between px-3 border-b border-white/[0.06] flex-shrink-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-zinc-400 uppercase tracking-wider">History</span>
{snapshots.length > 1 && (
<span className="px-1.5 text-[9px] h-4 inline-flex items-center justify-center rounded-full bg-white/[0.06] text-zinc-500 font-mono">{snapshots.length}</span>
)}
</div>
{snapshots.length > 1 && (
<button onClick={handleClear} className="p-1 rounded text-zinc-600 hover:text-red-400 hover:bg-red-400/10 transition-colors" title="Clear history">
<Trash2 size={12} />
</button>
)}
</div>
<div className="flex-1 overflow-y-auto p-1.5 space-y-0.5">
<HistoryPanel snapshots={snapshots} activeIndex={activeIndex} onJumpTo={handleJumpTo} onClear={handleClear} />
</div>
</div>
</div>
</div>
);
}
// ββ Small UI helpers (too small to extract) ββββββββββββββββββββββββββ
function ToolbarButton({ icon: Icon, onClick, disabled, title }: { icon: typeof Undo2; onClick: () => void; disabled?: boolean; title: string }) {
return (
<button onClick={onClick} disabled={disabled} title={title} aria-label={title} className="p-1.5 rounded-md text-zinc-500 hover:text-zinc-200 hover:bg-white/[0.06] disabled:opacity-20 disabled:hover:bg-transparent disabled:hover:text-zinc-500 transition-colors">
<Icon size={15} />
</button>
);
}
function MobileHeader({ canUndo, canRedo, onUndo, onRedo }: { canUndo: boolean; canRedo: boolean; onUndo: () => void; onRedo: () => void }) {
return (
<div className="h-11 flex items-center justify-between px-3 bg-[#161616] border-b border-white/[0.06] flex-shrink-0">
<span className="text-[13px] font-medium text-zinc-300">Photo Editor</span>
<div className="flex gap-0.5">
<ToolbarButton icon={Undo2} onClick={onUndo} disabled={!canUndo} title="Undo" />
<ToolbarButton icon={Redo2} onClick={onRedo} disabled={!canRedo} title="Redo" />
</div>
</div>
);
}
function MobileTabBar({ tab, onTabChange, snapshotCount }: { tab: "editor" | "history"; onTabChange: (t: "editor" | "history") => void; snapshotCount: number }) {
return (
<div className="h-12 bg-[#161616] border-t border-white/[0.06] flex flex-shrink-0">
<button onClick={() => onTabChange("editor")} className={`flex-1 flex items-center justify-center text-[11px] font-medium transition-colors ${tab === "editor" ? "text-zinc-200" : "text-zinc-600"}`}>
π¨ Editor
</button>
<button onClick={() => onTabChange("history")} className={`flex-1 flex items-center justify-center text-[11px] font-medium transition-colors ${tab === "history" ? "text-zinc-200" : "text-zinc-600"}`}>
πΈ History
{snapshotCount > 1 && (
<span className="ml-1.5 px-1.5 text-[9px] h-4 inline-flex items-center justify-center rounded-full bg-white/[0.06] text-zinc-500 font-mono">{snapshotCount}</span>
)}
</button>
</div>
);
}
Real-World Analogyβ
A save point in a video game. The game captures your exact state: position, inventory, health, progress. Later, you can restore to that exact moment. The save file is the memento.
When to Use Itβ
- Implement undo/redo with visual state snapshots
- Create save points or checkpoints
- State is cheap to copy (small objects, immutable data)
- Users need to jump to any previous state, not just step back
Both support undo/redo. Memento stores state snapshots: good when state is small and you want visual history. Command stores actions with inverse operations: good when state is large but operations are reversible. The photo editor uses Memento because you want to see thumbnails of each state, not a list of "brightness +10" operations.
When NOT to Use Itβ
If your state is large (a full document, a complex 3D scene), storing snapshots at every change is expensive. Use Command instead, which only stores the delta.
APIβ
- createHistory β Create a state history with undo/redo support