Flyweight Pattern
Share common state across many similar objects to minimize memory usage.
The Problemβ
You're building a text editor. Each character could be an object with font, size, color, and position. A million characters = a million objects = memory explosion.
The naive approach:
class Character {
constructor(
public char: string,
public font: string, // "Arial" repeated millions of times
public size: number, // 12 repeated millions of times
public color: string, // "#000000" repeated millions of times
public x: number,
public y: number
) {}
}
// Million characters = million copies of "Arial", 12, "#000000"
Intrinsic state (font, size, color) is duplicated. Massive memory waste.
The Solutionβ
Share intrinsic state. Store only extrinsic state (position) per instance:
import { memoize } from "@pithos/core/arkhe";
// Flyweight factory - returns shared style objects
const getStyle = memoize((font: string, size: number, color: string) => ({
font,
size,
color,
}));
// Characters only store position + reference to shared style
interface Character {
char: string;
style: ReturnType<typeof getStyle>; // shared!
x: number;
y: number;
}
// Million characters, but only a few unique styles
const char1: Character = {
char: "H",
style: getStyle("Arial", 12, "#000000"), // shared
x: 0,
y: 0,
};
const char2: Character = {
char: "i",
style: getStyle("Arial", 12, "#000000"), // same reference β not a copy
x: 10,
y: 0,
};
char1.style === char2.style; // true β same object in memory
Styles are shared via memoization. Memory usage drops dramatically.
Live Demoβ
Type text, change styles with the presets, and watch the counters. Toggle Flyweight on and off - the memory bar shows the difference in real time.
- flyweight.ts
- Usage
/**
* Flyweight pattern β style object pool via memoize.
*
* Same args = same object reference (shared intrinsic state).
* Without flyweight: every character gets its own copy.
*/
import { memoize } from "@pithos/core/eidos/flyweight/flyweight";
import type { CharStyle, EditorChar, EditorStats } from "./types";
/** Memoized style factory β same args = same object reference */
const memoizedCreateStyle = memoize(
(font: string, size: number, color: string): CharStyle => ({ font, size, color }),
(font: string, size: number, color: string) => `${font}|${size}|${color}`,
);
export function getSharedStyle(font: string, size: number, color: string): CharStyle {
return memoizedCreateStyle(font, size, color);
}
export function getCopiedStyle(font: string, size: number, color: string): CharStyle {
return { font, size, color };
}
export function resetPool(): void { memoizedCreateStyle.cache.clear(); }
const STYLE_OBJECT_SIZE = 64;
export function computeStats(chars: EditorChar[], useFlyweight: boolean): EditorStats {
const totalChars = chars.length;
if (totalChars === 0) return { totalChars: 0, styleObjects: 0, memoryBytes: 0, savedPercent: 0 };
if (useFlyweight) {
const refs = new Set(chars.map((c) => c.style));
const styleObjects = refs.size;
const memoryBytes = styleObjects * STYLE_OBJECT_SIZE;
const wouldBe = totalChars * STYLE_OBJECT_SIZE;
return { totalChars, styleObjects, memoryBytes, savedPercent: wouldBe > 0 ? Math.round(((wouldBe - memoryBytes) / wouldBe) * 100) : 0 };
}
return { totalChars, styleObjects: totalChars, memoryBytes: totalChars * STYLE_OBJECT_SIZE, savedPercent: 0 };
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
import { useState, useCallback, useMemo, useRef } from "react";
import { getSharedStyle, getCopiedStyle, computeStats, resetPool } from "@/lib/flyweight";
import { useContentEditable } from "./useContentEditable";
import { PRESETS } from "@/data/presets";
import type { EditorChar, StylePreset, EditorMode } from "@/lib/types";
export function useTextEditor() {
const [mode, setMode] = useState<EditorMode>("flyweight");
const [chars, setChars] = useState<EditorChar[]>([]);
const [activePreset, setActivePreset] = useState<StylePreset>(PRESETS[0]);
const presetRef = useRef(activePreset);
presetRef.current = activePreset;
const modeRef = useRef(mode);
modeRef.current = mode;
const { editorRef, handleBlur, restoreFocus, readInput, clearEditor } = useContentEditable(chars);
const stats = useMemo(() => computeStats(chars, mode === "flyweight"), [chars, mode]);
const noFlyweightStats = useMemo(() => (mode === "flyweight" ? computeStats(chars, false) : null), [chars, mode]);
const changePreset = useCallback((preset: StylePreset) => {
setActivePreset(preset);
requestAnimationFrame(() => restoreFocus());
}, [restoreFocus]);
const handleInput = useCallback(() => {
const { text, cursorPos } = readInput();
const { font, size, color } = presetRef.current;
const getStyle = modeRef.current === "flyweight" ? getSharedStyle : getCopiedStyle;
setChars((prev) => {
const oldLen = prev.length;
const newLen = text.length;
if (newLen > oldLen) {
const inserted = newLen - oldLen;
const insertPos = cursorPos - inserted;
const nc: EditorChar[] = [];
for (let i = 0; i < insertPos && i < oldLen; i++) nc.push({ ...prev[i], char: text[i] });
for (let i = insertPos; i < insertPos + inserted; i++) nc.push({ char: text[i], style: getStyle(font, size, color), index: i });
for (let i = insertPos; i < oldLen; i++) nc.push({ ...prev[i], char: text[i + inserted] });
return nc;
} else if (newLen < oldLen) {
const deleted = oldLen - newLen;
const nc: EditorChar[] = [];
for (let i = 0; i < cursorPos; i++) nc.push({ ...prev[i], char: text[i] });
for (let i = cursorPos + deleted; i < oldLen; i++) nc.push({ ...prev[i], char: text[i - deleted] });
return nc;
}
return prev.map((c, i) => ({ ...c, char: text[i] }));
});
}, [readInput]);
const handleReset = useCallback(() => { setChars([]); resetPool(); clearEditor(); }, [clearEditor]);
const handleModeSwitch = useCallback((newMode: EditorMode) => {
const getStyle = newMode === "flyweight" ? getSharedStyle : getCopiedStyle;
setChars((prev) => prev.map((c) => ({ ...c, style: getStyle(c.style.font, c.style.size, c.style.color) })));
setMode(newMode);
}, []);
return {
mode, activePreset, changePreset, stats, noFlyweightStats,
editorRef, handleInput, handleBlur, handleReset, handleModeSwitch,
};
}
Real-World Analogyβ
A font file. Your computer doesn't store a separate "A" image for every "A" on screen. It stores one "A" glyph and reuses it everywhere, just changing position and size.
When to Use Itβ
If you're creating thousands of objects that share most of their data (game entities, DOM nodes, text characters, map tiles), extract the shared part into a memoized factory. The more duplicates, the bigger the win.
When NOT to Use Itβ
If each object is unique with no shared state, there's nothing to pool. Flyweight adds a lookup cost that only pays off when many objects share the same intrinsic data.
In Functional TypeScriptβ
Flyweight is essentially memoization. memoize from Arkhe handles the pooling:
import { memoize } from "@pithos/core/arkhe";
// Any function that creates objects can become a flyweight factory
const getColor = memoize((r: number, g: number, b: number) => ({ r, g, b }));
// Same arguments = same object
getColor(255, 0, 0) === getColor(255, 0, 0); // true
APIβ
- memoize β Cache function results, effectively creating an object pool