PatternsLists & Performance
Deep Object Memoization
Implement deep equality checks to prevent unnecessary re-renders when dealing with complex object structures.
Deep Object Memoization
Problem
Comparing complex objects with === or shallow equality checks causes unnecessary re-renders because new object references are created even when the actual data hasn't changed.
Solution
Implement deep equality comparison to check if nested object values have actually changed before triggering updates.
type Primitive = string | number | boolean | null | undefined | symbol | bigint;
/**
* Check if value is a primitive type
*/
function isPrimitive(value: unknown): value is Primitive {
return (
value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'symbol' ||
typeof value === 'bigint'
);
}
/**
* Check if value is a plain object (not Array, Date, etc.)
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
}
/**
* Deep equality comparison for any type
*/
function deepEqual(a: unknown, b: unknown): boolean {
// Same reference or both NaN
if (Object.is(a, b)) {
return true;
}
// Primitive comparison
if (isPrimitive(a) || isPrimitive(b)) {
return a === b;
}
// Type mismatch
if (typeof a !== typeof b) {
return false;
}
// Array comparison
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) {
return false;
}
return a.every((item, index) => deepEqual(item, b[index]));
}
// Date comparison
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// RegExp comparison
if (a instanceof RegExp && b instanceof RegExp) {
return a.toString() === b.toString();
}
// Map comparison
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) {
return false;
}
for (const [key, value] of a) {
if (!b.has(key) || !deepEqual(value, b.get(key))) {
return false;
}
}
return true;
}
// Set comparison
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) {
return false;
}
for (const item of a) {
if (!b.has(item)) {
return false;
}
}
return true;
}
// Plain object comparison
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every((key) => {
return Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]);
});
}
// Fallback: not equal
return false;
}
/**
* Memoization cache with deep equality
*/
class DeepMemoCache<T> {
private cache = new Map<string, { value: T; timestamp: number }>();
private readonly ttl: number;
constructor(ttlMs = 5000) {
this.ttl = ttlMs;
}
/**
* Generate a stable key from complex objects
*/
private generateKey(args: unknown[]): string {
return JSON.stringify(args);
}
/**
* Check if cached value is still valid
*/
private isValid(timestamp: number): boolean {
return Date.now() - timestamp < this.ttl;
}
/**
* Get cached value if it exists and is valid
*/
public get(args: unknown[]): T | undefined {
const key = this.generateKey(args);
const cached = this.cache.get(key);
if (cached && this.isValid(cached.timestamp)) {
return cached.value;
}
if (cached) {
this.cache.delete(key);
}
return undefined;
}
/**
* Set cached value
*/
public set(args: unknown[], value: T): void {
const key = this.generateKey(args);
this.cache.set(key, { value, timestamp: Date.now() });
}
/**
* Clear all cached values
*/
public clear(): void {
this.cache.clear();
}
/**
* Get cache size
*/
public get size(): number {
return this.cache.size;
}
}
/**
* Create a memoized function with deep equality checking
*/
function memoizeDeep<T extends unknown[], R>(
fn: (...args: T) => R,
ttlMs = 5000
): (...args: T) => R {
const cache = new DeepMemoCache<R>(ttlMs);
return (...args: T): R => {
const cached = cache.get(args);
if (cached !== undefined) {
return cached;
}
const result = fn(...args);
cache.set(args, result);
return result;
};
}
// Practical example: expensive filtering operation
interface Product {
id: string;
name: string;
price: number;
category: string;
tags: string[];
}
interface FilterOptions {
minPrice?: number;
maxPrice?: number;
categories?: string[];
searchTerm?: string;
}
function filterProducts(products: Product[], options: FilterOptions): Product[] {
console.log('Filtering products...'); // This should only log when filters actually change
return products.filter((product) => {
if (options.minPrice !== undefined && product.price < options.minPrice) {
return false;
}
if (options.maxPrice !== undefined && product.price > options.maxPrice) {
return false;
}
if (options.categories && !options.categories.includes(product.category)) {
return false;
}
if (options.searchTerm) {
const term = options.searchTerm.toLowerCase();
return (
product.name.toLowerCase().includes(term) ||
product.tags.some((tag) => tag.toLowerCase().includes(term))
);
}
return true;
});
}
// Memoized version
const filterProductsMemo = memoizeDeep(filterProducts, 10000);
// Usage example
const products: Product[] = [
{ id: '1', name: 'Laptop', price: 1000, category: 'Electronics', tags: ['computer', 'work'] },
{ id: '2', name: 'Phone', price: 500, category: 'Electronics', tags: ['mobile', 'communication'] },
];
const options1: FilterOptions = { minPrice: 400, categories: ['Electronics'] };
const options2: FilterOptions = { minPrice: 400, categories: ['Electronics'] }; // Same values, different object
// Without memoization: runs twice
filterProducts(products, options1);
filterProducts(products, options2); // Runs again even though options are identical
// With memoization: runs once
filterProductsMemo(products, options1);
filterProductsMemo(products, options2); // Returns cached resultPerformance Note
Deep equality checks prevent unnecessary work when:
- Filter objects are recreated (e.g., React state updates)
- Arrays are mapped/filtered but contain the same data
- API responses return identical data with different object references
For a list of 1,000 products with complex filtering:
- Without memoization: 1,000 iterations on every render
- With memoization: 0 iterations when filters haven't changed
The performance cost of deep equality (typically less than 1ms for reasonable objects) is far lower than re-running expensive operations.
When to use:
- Expensive computations (filtering, sorting, mapping)
- Complex nested objects in state
- High-frequency updates (scroll, resize, input)
When to avoid:
- Simple primitive comparisons
- Functions that run in under 1ms
- Data that always changes