Created
August 17, 2025 06:03
-
-
Save adyba/23f06873edd4592fe86c26d09d80fb13 to your computer and use it in GitHub Desktop.
NgRx Signal Store with Undo/Redo feature
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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 }); | |
| }, | |
| })) | |
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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