Skip to content

Undo and Redo

Sygnal records undo history for one key of a component’s state. Each change to state[key] pushes the previous value onto state.history.past; undo moves it back, redo forward again. There are two ways to add it:

  • the undo behavior: uses = { history: undo({ key: 'doc' }) }, with canUndo / canRedo in the state and buttons wired through its options;
  • the undoable() model wrapper: model = undoable({ … }, { key: 'doc' }), when you want UNDO and REDO as plain actions of the component.

Both take the same options and keep the same history.

import { undo } from 'sygnal'
export function Editor({ state }) {
return (
<div>
<label>Note <textarea className="note" value={state.doc.text} /></label>
<button className="undo" disabled={!state.history.canUndo}>Undo</button>
<button className="redo" disabled={!state.history.canRedo}>Redo</button>
</div>
)
}
Editor.initialState = { doc: { text: '' } }
Editor.uses = { history: undo({ key: 'doc', coalesceMs: 500, undo: '.undo', redo: '.redo' }) }
Editor.intent = ({ DOM }) => ({ TYPE: DOM.input('.note').value() })
Editor.model = {
TYPE: (state, text) => ({ ...state, doc: { ...state.doc, text } }),
}

state.history is { past, future, canUndo, canRedo }. A click on Undo dispatches history.UNDO, which puts the newest snapshot from past back into state.doc and moves the current value to future. Any new change clears future.

coalesceMs: 500 joins the changes one action makes less than 500 ms apart into a single step, so typing “hello” quickly is undone at once rather than letter by letter. Without it every change is its own step.

coalesceMs alone joins quick repeats of any action: two fast clicks on a Larger button would also become one step. To group only typing, list the actions that may join in coalesce. Every other action is then always its own step:

import { undo } from 'sygnal'
export function Poster({ state }) {
return (
<div>
<label>Headline <input className="headline" value={state.poster.headline} /></label>
<button className="larger">Larger</button>
<button className="undo" disabled={!state.history.canUndo}>Undo</button>
<button className="redo" disabled={!state.history.canRedo}>Redo</button>
<h1 style={{ fontSize: `${state.poster.size}px` }}>{state.poster.headline}</h1>
</div>
)
}
Poster.initialState = { poster: { headline: '', size: 24 } }
Poster.uses = { history: undo({ key: 'poster', coalesce: ['HEADLINE'], coalesceMs: 1000, undo: '.undo', redo: '.redo' }) }
Poster.intent = ({ DOM }) => ({
HEADLINE: DOM.input('.headline').value(),
LARGER: DOM.click('.larger'),
})
Poster.model = {
HEADLINE: (state, headline) => ({ ...state, poster: { ...state.poster, headline } }),
LARGER: (state) => ({ ...state, poster: { ...state.poster, size: state.poster.size + 4 } }),
}

Typing “Sale” and then clicking Larger twice quickly makes three steps: the typing, and each click. A HEADLINE change joins only the previous change when that was a HEADLINE change too, less than coalesceMs earlier; a click in between starts a new step. With coalesce, coalesceMs defaults to 500.

A host intent action with a behavior action’s name replaces the behavior’s trigger. To undo with Ctrl+Z (⌘Z) as well as the button, leave out the undo / redo options and trigger the actions from the host:

import { undo, xs } from 'sygnal'
export function Editor({ state }) {
return (
<div>
<label>Note <textarea className="note" value={state.doc.text} /></label>
<button className="undo" disabled={!state.history.canUndo}>Undo</button>
<button className="redo" disabled={!state.history.canRedo}>Redo</button>
</div>
)
}
const keys = (DOM, shift) => DOM.select('document').events('keydown')
.filter(e => (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && e.shiftKey === shift)
Editor.initialState = { doc: { text: '' } }
Editor.uses = { history: undo({ key: 'doc', coalesceMs: 500 }) }
Editor.intent = ({ DOM }) => ({
TYPE: DOM.input('.note').value(),
'history.UNDO': xs.merge(DOM.click('.undo'), keys(DOM, false)),
'history.REDO': xs.merge(DOM.click('.redo'), keys(DOM, true)),
})
Editor.model = {
TYPE: (state, text) => ({ ...state, doc: { ...state.doc, text } }),
}

In a text field the browser runs its own undo on Ctrl+Z too. To leave undo to the app, prevent the default for those keys: events('keydown', { preventDefault: (e) => … }) (Preventing the default action).

undoable(model, options) wraps a model’s STATE reducers and adds UNDO and REDO entries for your intent to trigger. state.history is { past, future } (no calculated fields), and it isn’t there until the first change, so read it with a default. In this demo, a stand-in server has the saved note:

Demo server
export default {
'GET /api/note': () => ({ json: { text: 'Buy milk' } }),
}
Editor.jsx
import { undoable } from 'sygnal'
export function Editor({ state }) {
const { past, future } = state.history || { past: [], future: [] }
return (
<div>
<label>Note <textarea className="note" value={state.doc.text} /></label>
<button className="undo" disabled={past.length === 0}>Undo</button>
<button className="redo" disabled={future.length === 0}>Redo</button>
</div>
)
}
Editor.initialState = { doc: { text: '' } }
Editor.intent = ({ DOM }) => ({ TYPE: DOM.input('.note').value(), UNDO: DOM.click('.undo'), REDO: DOM.click('.redo') })
Editor.model = undoable({
BOOTSTRAP: { HTTP: () => ({ url: '/api/note', ok: 'LOADED' }) },
LOADED: (state, doc) => ({ ...state, doc }),
TYPE: (state, text) => ({ ...state, doc: { ...state.doc, text } }),
}, { key: 'doc', coalesceMs: 500, resetOn: ['LOADED'] })

resetOn: ['LOADED'] clears the history when the note is loaded: undo shouldn’t bring back the empty draft from before the load. Actions in resetOn are not recorded themselves.

OptionDefault
key(required)The state key whose value is recorded; an array of keys (['todo', 'done']) records them together, as one { todo, done } value per step
limit100The most steps kept in past; the oldest are dropped
trackevery action with a STATE reducerRecord only these actions’ changes. Built-in actions (INITIALIZE, BOOTSTRAP, …) are recorded only when listed
coalesceMs0 (off); 500 with coalesceChanges by the same action within this many ms join one step
coalesceevery actionOnly these actions’ changes join a step (typing); every other action is always its own step
resetOn[]Actions that clear the history
undo, redo—(undo() only) A selector (or a control) whose clicks dispatch UNDO / REDO

A change is a reducer result whose state[key] is a different object than before (with an array of keys: any of them), so reducers that return new objects (as Sygnal reducers do) are recorded. UNDO and REDO make no change when there is nothing to undo or redo. A model entry of your own for UNDO / REDO ('history.UNDO' with the behavior) runs after the built-in step.

A key the state doesn’t have is recorded as missing: undoing back to such a step removes the key again (it isn’t set to undefined). With one key, a missing key and one set to undefined are the same value; with an array of keys they differ, so a key that appears or goes (even as undefined) is a change.

Snapshots are the old values themselves, not copies. Keep key on the part of the state the user edits (doc), not on the whole state, so the history doesn’t hold every loading flag and list position too.

For the same reason, when you save the state, save state.doc and leave history out. With persist(), pick the document:

import { persist } from 'sygnal'
Editor.persist = persist({ key: 'note', pick: ['doc'] })

After a reload the note is back and the history starts empty, so the first undo doesn’t reach into the previous visit.

A behavior on the same host can mark its actions as one gesture with undoStep. sortable does: with undo({ key: 'tasks' }), a whole drag, pointer or keyboard, is one undo step, recorded when the item is dropped (sort.DROPPED). The live keyboard moves and a cancelled drag (Escape, a drop where it started) add no step. While a drag has moved something, state.history.base holds the value from before it; it is gone once the drag is dropped or cancelled. Another recorded change during a drag (an item added while one is lifted) records the value from before the drag, so it stays reachable, and the drag so far joins that change’s step. UNDO during a drag goes back to the value from before it (the drag so far is the step undone); REDO during a drag records that value too, in place of the half-moved one. A drag that ends without a drop after something else changed the list (an item added, an undo; see Drag and Drop) leaves the item where it is: when that leaves the value different from the one before the drag, it is recorded as one step, else nothing is; either way nothing stays pending. Only the behavior that started a gesture completes it: with two gesture behaviors on one host, one’s completing action doesn’t record the other’s gesture. This works with undo() (the behavior), in either uses order; undoable() can’t see the host’s behaviors.

A sortable with several lists (from: ['todo', 'done']) moves items between them, so record them together: undo({ key: ['todo', 'done'] }). With one of them as the key, an undo after a move between lists puts that list back but not the other, and the item is in both.

track and coalesce treat a gesture as one action: naming any of the behavior’s actions ('sort.DROPPED', 'sort.KEY', …) records drags, or lets quick drops join one step. A track list that names none of them leaves drags unrecorded, like any untracked change: undo steps over the tracked changes only, and a drag in progress isn’t undone on its own. coalesceMs without coalesce doesn’t join drops: two quick drags are two steps. A name in resetOn ('sort.DROPPED') clears the history when that action runs.

A name in track or resetOn with no model entry is SYG226 (a warning): its changes are never recorded, or the history is never cleared. It is usually a typo or a renamed action. sygnal-check reports it statically; undoable() reports it when diagnostics are on, and undo() when the component is first created. For undo(), the names are the host component’s actions, including other behaviors’ namespaced ones ('pager.NEXT'); a name under a behavior’s key that isn’t one of its actions ('sort.DROPED') is reported too.