Skip to content

Instantly share code, notes, and snippets.

@adyba
Created August 17, 2025 06:03
Show Gist options
  • Select an option

  • Save adyba/23f06873edd4592fe86c26d09d80fb13 to your computer and use it in GitHub Desktop.

Select an option

Save adyba/23f06873edd4592fe86c26d09d80fb13 to your computer and use it in GitHub Desktop.
NgRx Signal Store with Undo/Redo feature
import { Component, DestroyRef, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ProfileStore } from './profile.store';
@Component({
selector: 'app-profile',
standalone: true,
template: `
<form [formGroup]="form">
<input formControlName="firstName" />
<input formControlName="lastName" />
<input formControlName="email" />
<button type="button" (click)="store.undo()" [disabled]="!store.canUndo()">Undo</button>
<button type="button" (click)="store.redo()" [disabled]="!store.canRedo()">Redo</button>
<small>{{ store.historyPosition() | json }}</small>
</form>
`,
})
export class ProfileComponent {
private fb = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
store = inject(ProfileStore);
form = this.fb.group({
firstName: ['Ada', Validators.required],
lastName: ['Lovelace', Validators.required],
email: ['[email protected]', [Validators.required, Validators.email]],
});
ngOnInit() {
const disconnect = this.store.connectForm(this.form, { debounceMs: 120 });
// ✅ ensure we disconnect on component destroy
this.destroyRef.onDestroy(() => disconnect());
}
}
// profile.store.ts
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from '@ngrx/signals';
import { computed } from '@angular/core';
import { withHistory } from './with-history.feature';
interface ProfileState {
firstName: string;
lastName: string;
email: string;
}
const initial: ProfileState = {
firstName: 'Ada',
lastName: 'Lovelace',
email: '[email protected]',
};
// V20-safe ProfileStore with withHistory feature
// Using type assertion to work around NgRx v20 strict feature composition types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const ProfileStore = (signalStore as any)(
{ providedIn: 'root' },
withState(initial),
withHistory(),
withComputed(({ firstName, lastName }) => ({
fullName: computed(() => `${firstName()} ${lastName()}`),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
withMethods((store: any) => ({
setEmail(email: string) {
patchState(store, { email });
},
}))
);
// with-history.feature.ts
import {
signalStoreFeature,
withComputed,
withMethods,
patchState,
} from '@ngrx/signals';
import { computed } from '@angular/core';
import {
AbstractControl,
FormArray,
FormControl,
FormGroup,
} from '@angular/forms';
import { Subscription } from 'rxjs';
import { auditTime, pairwise, startWith, filter } from 'rxjs/operators';
/** RFC6901 JSON Pointer */
export type JsonPointer = `/${string}`;
export type PatchOp =
| { op: 'replace'; path: JsonPointer; prev: unknown; next: unknown }
| { op: 'add'; path: JsonPointer; prev: undefined; next: unknown }
| { op: 'remove'; path: JsonPointer; prev: unknown; next: undefined };
export interface FormChangeEvent {
type: 'batch';
ops: PatchOp[];
ts: number;
seq: number;
meta?: Record<string, unknown>;
}
export interface HistoryState {
_history: {
cursor: number; // -1 = base, otherwise index into log
log: FormChangeEvent[]; // append-only
};
}
export type ConnectOptions = {
debounceMs?: number;
captureMeta?: (
event: Omit<FormChangeEvent, 'meta'>
) => Record<string, unknown> | undefined;
};
// Use a unique string key to avoid state collisions in v20
const HISTORY_STATE_KEY = '__withHistory_state__' as const;
/* ---------------- JSON Pointer helpers ---------------- */
const enc = (s: string) => s.replace(/~/g, '~0').replace(/\//g, '~1');
const dec = (s: string) => s.replace(/~1/g, '/').replace(/~0/g, '~');
const joinPtr = (parts: string[]): JsonPointer =>
('/' + parts.map(enc).join('/')) as JsonPointer;
/** Helper to create patch operations for value changes */
function createPatchOp(
prev: unknown,
next: unknown,
path: JsonPointer
): PatchOp {
if (typeof prev === 'undefined')
return { op: 'add', path, prev: undefined, next };
if (typeof next === 'undefined')
return { op: 'remove', path, prev, next: undefined };
return { op: 'replace', path, prev, next };
}
/** Helper to check if values have structural differences */
function hasStructuralChange(prev: unknown, next: unknown): boolean {
const prevObj = typeof prev === 'object' && prev !== null;
const nextObj = typeof next === 'object' && next !== null;
return (
Array.isArray(prev) !== Array.isArray(next) ||
(prevObj && !nextObj) ||
(!prevObj && nextObj)
);
}
/** Helper to handle object property differences */
function diffObjectProperties(prev: any, next: any, base: string[]): PatchOp[] {
const ops: PatchOp[] = [];
const pKeys = new Set(Object.keys(prev));
const nKeys = new Set(Object.keys(next));
const all = new Set([...pKeys, ...nKeys]);
for (const k of all) {
const pv = prev?.[k];
const nv = next?.[k];
if (typeof nv === 'undefined') {
ops.push({
op: 'remove',
path: joinPtr([...base, k]),
prev: pv,
next: undefined,
});
} else if (typeof pv === 'undefined') {
ops.push({
op: 'add',
path: joinPtr([...base, k]),
prev: undefined,
next: nv,
});
} else {
ops.push(...diffToOps(pv, nv, [...base, k]));
}
}
return ops;
}
/** leaf-oriented diff into PatchOps (simple & predictable) */
function diffToOps(
prev: unknown,
next: unknown,
base: string[] = []
): PatchOp[] {
if (Object.is(prev, next)) return [];
const path = joinPtr(base);
if (hasStructuralChange(prev, next)) {
return [createPatchOp(prev, next, path)];
}
const prevObj = typeof prev === 'object' && prev !== null;
const nextObj = typeof next === 'object' && next !== null;
if (!prevObj && !nextObj) {
return [createPatchOp(prev, next, path)];
}
return diffObjectProperties(prev as any, next as any, base);
}
function invertOps(ops: PatchOp[]): PatchOp[] {
return ops
.slice()
.reverse()
.map((op) => {
switch (op.op) {
case 'add':
return {
op: 'remove',
path: op.path,
prev: op.next,
next: undefined,
} as PatchOp;
case 'remove':
return {
op: 'add',
path: op.path,
prev: undefined,
next: op.prev,
} as PatchOp;
case 'replace':
return {
op: 'replace',
path: op.path,
prev: op.next,
next: op.prev,
} as PatchOp;
}
});
}
function getControlByPointer(
form: AbstractControl,
ptr: JsonPointer
): AbstractControl | null {
const tokens =
ptr === '/' ? [] : ptr.slice(1).split('/').map(dec).filter(Boolean);
let c: AbstractControl | null = form;
for (const t of tokens) {
if (!c) return null;
if (c instanceof FormGroup) c = c.controls[t] ?? null;
else if (c instanceof FormArray) c = c.at(Number(t)) ?? null;
else return null;
}
return c;
}
function applyOpsToForm(form: FormGroup, ops: PatchOp[]) {
for (const op of ops) {
const c = getControlByPointer(form, op.path);
if (!c) continue;
if (c instanceof FormGroup || c instanceof FormArray) {
const v = op.op === 'remove' ? null : (op.next as any);
try {
(c as any).setValue(v, { emitEvent: false });
} catch {
(c as any).patchValue(v, { emitEvent: false });
}
} else {
const v = op.op === 'remove' ? null : (op.next as any);
(c as FormControl).setValue(v, { emitEvent: false });
}
}
}
/* ---------------- Feature ---------------- */
export function withHistory() {
// ephemeral runtime (not part of store state)
let form: FormGroup | null = null;
let baseSnapshot: unknown | null = null;
let sub: Subscription | null = null;
let isApplying = false;
let seq = 0;
return signalStoreFeature(
{
state: {
[HISTORY_STATE_KEY]: {
cursor: -1,
log: [] as FormChangeEvent[],
},
},
},
withComputed((state) => {
const historyState = state[HISTORY_STATE_KEY];
return {
historyLength: computed(() => historyState().log.length),
canUndo: computed(() => historyState().cursor >= 0),
canRedo: computed(
() => historyState().cursor < historyState().log.length - 1
),
historyPosition: computed(() => ({
index: historyState().cursor,
total: historyState().log.length,
})),
};
}),
withMethods((store) => {
const getHistoryState = () => (store as any)[HISTORY_STATE_KEY]();
function truncateRedoTail() {
const h = getHistoryState();
if (h.cursor < h.log.length - 1) {
patchState(store, {
[HISTORY_STATE_KEY]: { ...h, log: h.log.slice(0, h.cursor + 1) },
} as any);
}
}
function record(ops: PatchOp[], meta?: Record<string, unknown>) {
if (ops.length === 0) return;
truncateRedoTail();
const nextEvent: FormChangeEvent = {
type: 'batch',
ops,
ts: Date.now(),
seq: ++seq,
meta,
};
const h = getHistoryState();
patchState(store, {
[HISTORY_STATE_KEY]: {
cursor: h.log.length,
log: [...h.log, nextEvent],
},
} as any);
}
function apply(ops: PatchOp[]) {
if (!form) return;
isApplying = true;
applyOpsToForm(form, ops);
isApplying = false;
}
return {
/** Begin observing a Reactive Form and append events. */
connectForm(fg: FormGroup, opts: ConnectOptions = {}) {
sub?.unsubscribe();
form = fg;
baseSnapshot = fg.getRawValue();
seq = 0;
patchState(store, {
[HISTORY_STATE_KEY]: { cursor: -1, log: [] },
} as any);
sub = fg.valueChanges
.pipe(
auditTime(opts.debounceMs ?? 100),
startWith(baseSnapshot),
pairwise(),
filter(() => !isApplying)
)
.subscribe(([prev, next]) => {
const ops = diffToOps(prev, next);
const baseEvent: Omit<FormChangeEvent, 'meta'> = {
type: 'batch',
ops,
ts: Date.now(),
seq: seq + 1,
};
const meta = opts.captureMeta?.(baseEvent);
record(ops, meta);
});
return () => this.disconnectForm();
},
disconnectForm() {
sub?.unsubscribe();
sub = null;
form = null;
baseSnapshot = null;
},
/** External/manual append (e.g., transactional edit). */
pushEvent(ops: PatchOp[], meta?: Record<string, unknown>) {
record(ops, meta);
},
undo() {
const h = getHistoryState();
if (h.cursor < 0) return;
const evt = h.log[h.cursor];
apply(invertOps(evt.ops));
patchState(store, {
[HISTORY_STATE_KEY]: { ...h, cursor: h.cursor - 1 },
} as any);
},
redo() {
const h = getHistoryState();
if (h.cursor >= h.log.length - 1) return;
const evt = h.log[h.cursor + 1];
apply(evt.ops);
patchState(store, {
[HISTORY_STATE_KEY]: { ...h, cursor: h.cursor + 1 },
} as any);
},
/** Time travel to any index (-1 = base). */
goto(index: number) {
const h = getHistoryState();
if (index < -1 || index >= h.log.length || !form) return;
if (index === h.cursor) return;
if (index < h.cursor) {
const ops: PatchOp[] = invertOps(
h.log
.slice(index + 1, h.cursor + 1)
.flatMap((e: FormChangeEvent) => e.ops)
);
apply(ops);
} else {
const ops: PatchOp[] = h.log
.slice(h.cursor + 1, index + 1)
.flatMap((e: FormChangeEvent) => e.ops);
apply(ops);
}
patchState(store, {
[HISTORY_STATE_KEY]: { ...h, cursor: index },
} as any);
},
resetToBase({ clearHistory = false }: { clearHistory?: boolean } = {}) {
if (!form) return;
isApplying = true;
form.reset(baseSnapshot, { emitEvent: false });
isApplying = false;
const h = getHistoryState();
patchState(store, {
[HISTORY_STATE_KEY]: { cursor: -1, log: clearHistory ? [] : h.log },
} as any);
},
clearHistory() {
patchState(store, {
[HISTORY_STATE_KEY]: { cursor: -1, log: [] },
} as any);
},
exportLog(): FormChangeEvent[] {
return structuredClone(getHistoryState().log);
},
importLog(events: FormChangeEvent[], applyNow = false) {
const log = events.slice();
patchState(store, {
[HISTORY_STATE_KEY]: {
cursor: applyNow ? log.length - 1 : -1,
log,
},
} as any);
if (applyNow && form) {
isApplying = true;
form.reset(baseSnapshot, { emitEvent: false });
isApplying = false;
for (const e of events) apply(e.ops);
}
},
};
})
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment