Loading content...
Every time you call order.totalPrice, should the system iterate through all line items and sum them? Every time you access user.displayName, should it concatenate first and last names? These computed properties—values derived from other data—are accessed far more frequently than they change.
Without caching, computed properties recalculate on every access. In hot paths accessed thousands of times per second, this redundancy becomes a measurable performance drain. Yet naive caching of computed values introduces a dangerous problem: cache invalidation.
This page explores how to cache computed properties effectively while maintaining data consistency. You'll learn patterns for automatic invalidation, dependency tracking, and the architectural decisions that make cached computations both fast and correct.
By the end of this page, you will understand when and why to cache computed properties, implement self-invalidating cached properties, design dependency tracking systems for complex computed values, and apply reactive caching patterns used in modern frameworks.
A computed property is a value derived from one or more source properties. Unlike stored properties that hold data directly, computed properties calculate their value dynamically.
Examples of Computed Properties:
1234567891011121314151617181920212223242526272829303132333435363738
class Person { firstName: string; lastName: string; birthDate: Date; // Computed from firstName + lastName get fullName(): string { return `${this.firstName} ${this.lastName}`; } // Computed from birthDate + current date get age(): number { const today = new Date(); let age = today.getFullYear() - this.birthDate.getFullYear(); const monthDiff = today.getMonth() - this.birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < this.birthDate.getDate())) { age--; } return age; }} class ShoppingCart { items: CartItem[]; // Computed from all items - potentially expensive get subtotal(): number { return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0); } get taxAmount(): number { return this.subtotal * 0.08; // Calls subtotal again! } get total(): number { return this.subtotal + this.taxAmount; // Subtotal calculated 3x! }}Notice how total depends on subtotal, which is recalculated for each access. With 100 cart items, a single call to total iterates the items array three times! Caching intermediate results eliminates this redundancy.
The Caching Decision:
Not all computed properties benefit from caching. Consider:
| Factor | Cache | Don't Cache |
|---|---|---|
| Computation cost | Expensive (iterations, I/O) | Trivial (simple math) |
| Access frequency | High (accessed repeatedly) | Low (accessed once) |
| Change frequency | Low (source rarely changes) | High (constant changes) |
| Dependency count | Few dependencies | Many volatile dependencies |
| Memory impact | Reasonable cache size | Large cached objects |
The simplest approach caches the computed value and provides a manual invalidation method:
Manual Invalidation Pattern:
1234567891011121314151617181920212223242526272829303132333435363738394041
class ShoppingCart { private _items: CartItem[] = []; private _subtotalCache: number | null = null; get items(): readonly CartItem[] { return this._items; } get subtotal(): number { if (this._subtotalCache === null) { console.log('Computing subtotal...'); this._subtotalCache = this._items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ); } return this._subtotalCache; } // Any mutation must invalidate the cache addItem(item: CartItem): void { this._items.push(item); this.invalidateCache(); } removeItem(itemId: string): void { this._items = this._items.filter(i => i.id !== itemId); this.invalidateCache(); } updateQuantity(itemId: string, quantity: number): void { const item = this._items.find(i => i.id === itemId); if (item) { item.quantity = quantity; this.invalidateCache(); } } private invalidateCache(): void { this._subtotalCache = null; }}Decorator-Based Cached Properties:
To reduce boilerplate, create a reusable decorator for cached properties:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// Decorator that caches property values until invalidatedfunction CachedProperty(invalidateOn?: string[]) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { const getter = descriptor.get; if (!getter) { throw new Error('@CachedProperty can only be applied to getters'); } const cacheKey = Symbol(`__cached_${propertyKey}`); const validKey = Symbol(`__valid_${propertyKey}`); descriptor.get = function () { if (!this[validKey]) { this[cacheKey] = getter.call(this); this[validKey] = true; } return this[cacheKey]; }; // Add invalidation method if (!target.__invalidateCache) { target.__invalidateCache = function (property?: string) { if (property) { const validSymbol = Symbol.for(`__valid_${property}`); this[validSymbol] = false; } else { // Invalidate all cached properties Object.getOwnPropertySymbols(this).forEach(sym => { if (sym.description?.startsWith('__valid_')) { this[sym] = false; } }); } }; } return descriptor; };} // Usageclass Order { items: OrderItem[] = []; @CachedProperty() get subtotal(): number { return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0); } @CachedProperty() get tax(): number { return this.subtotal * 0.08; // Uses cached subtotal } @CachedProperty() get total(): number { return this.subtotal + this.tax; // Uses cached values } addItem(item: OrderItem): void { this.items.push(item); (this as any).__invalidateCache(); // Invalidate all }}Manual invalidation is error-prone—developers must remember to invalidate caches whenever source data changes. Modern reactive systems solve this through automatic dependency tracking.
Observable Properties with Computed Values:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Simple reactive system with automatic invalidationclass Observable<T> { private _value: T; private subscribers: Set<() => void> = new Set(); constructor(initial: T) { this._value = initial; } get value(): T { // Track that this observable was read Computed.trackDependency(this); return this._value; } set value(newValue: T) { if (this._value !== newValue) { this._value = newValue; this.notify(); } } subscribe(callback: () => void): () => void { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } private notify(): void { this.subscribers.forEach(cb => cb()); }} class Computed<T> { private static currentComputation: Computed<any> | null = null; private _value: T | undefined; private _isValid: boolean = false; private dependencies: Set<Observable<any>> = new Set(); constructor(private readonly compute: () => T) {} static trackDependency(observable: Observable<any>): void { if (Computed.currentComputation) { Computed.currentComputation.dependencies.add(observable); observable.subscribe(() => { Computed.currentComputation!.invalidate(); }); } } get value(): T { if (!this._isValid) { // Clear old dependencies this.dependencies.clear(); // Track dependencies during computation const previousComputation = Computed.currentComputation; Computed.currentComputation = this; try { this._value = this.compute(); this._isValid = true; } finally { Computed.currentComputation = previousComputation; } } return this._value!; } private invalidate(): void { this._isValid = false; }} // Usage - automatic invalidation!const quantity = new Observable(2);const price = new Observable(10); const total = new Computed(() => quantity.value * price.value); console.log(total.value); // 20 (computed)console.log(total.value); // 20 (cached) price.value = 15; // Automatically invalidates totalconsole.log(total.value); // 30 (recomputed)This is the core mechanism behind Vue's reactivity system, MobX observables, and SolidJS signals. They automatically track which observables a computed value depends on and invalidate the cache when any dependency changes.
An alternative to dependency tracking is version-based invalidation. Each source property maintains a version counter that increments on change. Cached values store the version when computed and check validity against current versions.
Version-Based Caching:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
class VersionedCache<T> { private cachedValue: T | undefined; private cachedVersion: number = -1; constructor( private readonly compute: () => T, private readonly getVersion: () => number ) {} get value(): T { const currentVersion = this.getVersion(); if (this.cachedVersion !== currentVersion) { this.cachedValue = this.compute(); this.cachedVersion = currentVersion; } return this.cachedValue!; } invalidate(): void { this.cachedVersion = -1; }} // Domain object with versioned cachingclass Document { private _version: number = 0; private _content: string = ''; private _wordCountCache: VersionedCache<number>; private _readingTimeCache: VersionedCache<number>; constructor() { this._wordCountCache = new VersionedCache( () => this._content.split(/\s+/).filter(w => w.length > 0).length, () => this._version ); this._readingTimeCache = new VersionedCache( () => Math.ceil(this.wordCount / 200), // ~200 words per minute () => this._version ); } get content(): string { return this._content; } set content(value: string) { if (this._content !== value) { this._content = value; this._version++; // Increment version, caches auto-invalidate } } get wordCount(): number { return this._wordCountCache.value; } get readingTimeMinutes(): number { return this._readingTimeCache.value; }}Advantages of Version-Based Invalidation:
You now understand how to cache computed properties effectively—from manual invalidation to automatic dependency tracking to version-based approaches. Next, we'll explore treating cache as a first-class collaborator in your object designs, with explicit cache dependencies and lifecycle management.