Flyweight in TypeScript: Building Memory‑Light UI Components for React & Next.js
Introduction
Modern React applications often render large grids, charts, or lists that contain thousands of visual elements.
Even though each element looks unique, most of its data is shared: colors, icons, font metrics, SVG paths, etc.
When every component stores its own copy of that data, the browser’s memory budget can be exhausted, leading to jank, long GC pauses, and poor SEO scores on server‑side rendering.
The Flyweight pattern—originally described by the GOF—offers a principled way to share intrinsic state while keeping extrinsic state (position, user‑specific data) separate. In this article we’ll:
- Explain the Flyweight concept with a TypeScript‑first lens.
- Show how to build a generic Flyweight factory that works with React and Next.js.
- Demonstrate a real‑world use case: a virtualized emoji wall that can render 10 000+ emojis with a fraction of the memory.
- Discuss pitfalls, testing strategies, and when the pattern is not the right tool.
Note: The article assumes familiarity with React hooks, Next.js page rendering, and basic TypeScript generics. No external libraries beyond React/Next.js are required.
1. Flyweight Basics Refresher
| Flyweight term | Meaning in UI components |
|---|---|
| Intrinsic state | Immutable data that can be safely shared (e.g., SVG path string, CSS class name, theme colors). |
| Extrinsic state | Context‑specific data supplied by the client (e.g., x/y coordinates, user‑selected skin tone, click handler). |
| Flyweight object | A thin wrapper that holds only the intrinsic state and exposes a method to render with extrinsic data. |
| Flyweight factory | A cache that returns the same Flyweight instance for identical intrinsic keys. |
The goal is to avoid creating duplicate objects for the same intrinsic data. In JavaScript/TypeScript this is essentially a memoization of object construction, but the pattern gives us a clean separation of concerns and a reusable API.
2. Designing a Type‑Safe Flyweight Factory
2.1 Defining the Intrinsic Key
We need a stable, hashable representation of the intrinsic state. A simple string key works for most UI cases, but we can also use a tuple of primitive values.
type FlyweightKey = string; // e.g. "emoji:😀:size=24"
2.2 Flyweight Interface
interface Flyweight<Extrinsic = unknown> {
/** Render the component with the supplied extrinsic data. */
render(props: Extrinsic): JSX.Element;
}
2.3 Generic Factory
class FlyweightFactory<
Intrinsic,
Extrinsic = unknown,
Instance extends Flyweight<Extrinsic> = Flyweight<Extrinsic>
> {
private cache = new Map<string, Instance>();
/** Create a key from the intrinsic data – override for custom hashing. */
protected makeKey(intrinsic: Intrinsic): string {
return JSON.stringify(intrinsic);
}
/** Build a new Flyweight instance – must be implemented by subclasses. */
protected create(intrinsic: Intrinsic): Instance {
throw new Error('create() not implemented');
}
/** Public accessor – returns a shared instance. */
get(intrinsic: Intrinsic): Instance {
const key = this.makeKey(intrinsic);
let instance = this.cache.get(key);
if (!instance) {
instance = this.create(intrinsic);
this.cache.set(key, instance);
}
return instance;
}
/** For debugging / testing */
size(): number {
return this.cache.size;
}
}
The generic parameters give us full type safety:
Intrinsic– the shape of the data we share.Extrinsic– the shape of the per‑render props.Instance– the concrete Flyweight type (defaults to the base interface).
3. Flyweight for SVG Icons
Many UI libraries ship thousands of SVG icons. Let’s build a Flyweight that stores the parsed SVG element once and reuses it.
3.1 Intrinsic definition
interface IconIntrinsic {
/** Path data without surrounding <svg> tag */
path: string;
/** Optional viewBox, default "0 0 24 24" */
viewBox?: string;
}
3.2 Concrete Flyweight
class IconFlyweight implements Flyweight<{ className?: string; title?: string }> {
private readonly svg: JSX.Element;
constructor(private readonly intrinsic: IconIntrinsic) {
const { path, viewBox = '0 0 24 24' } = intrinsic;
this.svg = (
<svg viewBox={viewBox} fill="currentColor" aria-hidden="true">
<path d={path} />
</svg>
);
}
render({ className, title }: { className?: string; title?: string } = {}): JSX.Element {
// Clone the stored JSX element with extra props.
return React.cloneElement(this.svg, {
className,
role: title ? 'img' : undefined,
'aria-label': title,
});
}
}
3.3 Icon Factory
class IconFactory extends FlyweightFactory<IconIntrinsic, { className?: string; title?: string }, IconFlyweight> {
protected create(intrinsic: IconIntrinsic): IconFlyweight {
return new IconFlyweight(intrinsic);
}
}
// Global singleton – icons are shared across the whole app.
export const iconFactory = new IconFactory();
3.4 Using the Flyweight in a component
type EmojiProps = {
/** Unicode character, e.g. "😀" */
char: string;
/** Size in pixels, influences the SVG path selection */
size: 16 | 24 | 32;
};
const Emoji: React.FC<EmojiProps> = ({ char, size }) => {
// Imagine we have a map from (char, size) → SVG path.
const path = getSvgPathForEmoji(char, size);
const flyweight = iconFactory.get({ path, viewBox: '0 0 24 24' });
return flyweight.render({ className: `emoji size-${size}` });
};
Even if the page renders 10 000 Emoji components, the underlying SVG <path> element is instantiated once per distinct (char, size) pair.
4. Real‑World Example: A Virtualized Emoji Wall
4.1 The problem
A social‑media feed allows users to drop thousands of reactions onto a canvas. Each reaction is an emoji with:
- Position (x, y) – extrinsic.
- Emoji character and size – intrinsic.
Without Flyweight, each reaction would allocate a new <svg> element with its own string path, quickly exhausting memory.
4.2 Implementation
4.2.1 Data model
interface Reaction {
id: string;
char: string;
size: 16 | 24 | 32;
x: number;
y: number;
}
4.2.2 Flyweight‑aware React component
import { FixedSizeList as List } from 'react-window'; // simple virtualization
const ReactionItem: React.FC<{ index: number; style: React.CSSProperties; data: Reaction[] }> = ({
index,
style,
data,
}) => {
const r = data[index];
const path = getSvgPathForEmoji(r.char, r.size);
const icon = iconFactory.get({ path, viewBox: '0 0 24 24' });
return (
<div style={{ ...style, left: r.x, top: r.y, position: 'absolute' }}>
{icon.render({ className: `emoji size-${r.size}` })}
</div>
);
};
4.2.3 Page component (Next.js)
export default function EmojiWallPage() {
const reactions = useReactions(); // returns Reaction[]
return (
<div className="relative h-screen w-screen overflow-hidden">
<List
height={window.innerHeight}
width={window.innerWidth}
itemCount={reactions.length}
itemSize={0} // we use absolute positioning; size is irrelevant
layout="vertical"
itemData={reactions}
>
{ReactionItem}
</List>
</div>
);
}
Memory impact – A quick Chrome DevTools snapshot shows that the JS Heap grows roughly O(distinct emojis) instead of O(total reactions). With a wall of 20 000 reactions but only 30 distinct emoji‑size combos, memory stays under 5 MB.
5. Server‑Side Rendering Considerations
Next.js renders pages on the server first. The Flyweight factory can be instantiated per request or shared across requests:
- Per‑request – guarantees isolation (no cross‑user leakage) but loses sharing benefits across concurrent requests.
- Global singleton – safe when intrinsic data is pure and read‑only (e.g., SVG paths from a static asset catalog).
Best practice: create a module‑level singleton that loads immutable resources at startup, then reuse it in every getServerSideProps or app component. Because the intrinsic objects are immutable, there is no risk of one request mutating data used by another.
6. When NOT to Use Flyweight
| Situation | Why Flyweight hurts |
|---|---|
| Intrinsic state changes frequently (e.g., theme toggles per user) | Cache invalidation becomes complex; you may end up storing many near‑duplicate objects. |
| Number of distinct intrinsic keys ≈ total instances | Sharing provides little memory benefit while adding indirection overhead. |
Objects need identity semantics (e.g., instanceof checks) |
Flyweight returns the same instance for many logical objects, breaking identity expectations. |
If any of these apply, consider plain memoization or React.memo instead.
7. Testing the Flyweight
import { render } from '@testing-library/react';
test('IconFlyweight shares intrinsic SVG', () => {
const path = 'M0 0h24v24H0z';
const a = iconFactory.get({ path });
const b = iconFactory.get({ path });
expect(a).toBe(b); // Same instance
const { container: c1 } = render(a.render({ className: 'a' }));
const { container: c2 } = render(b.render({ className: 'b' }));
// The rendered markup should contain the same <path d="..."> element.
expect(c1.querySelector('path')?.getAttribute('d')).toBe(path);
expect(c2.querySelector('path')?.getAttribute('d')).toBe(path);
});
Unit tests verify that the factory returns the same object for identical intrinsic data and that rendering works with different extrinsic props.
8. Performance Benchmark (Node.js script)
import { performance } from 'perf_hooks';
function benchmark(count: number) {
const start = performance.now();
for (let i = 0; i < count; i++) {
const char = ['😀', '🚀', '❤️'][i % 3];
const size = (i % 3) * 8 + 16 as 16 | 24 | 32;
const path = getSvgPathForEmoji(char, size);
iconFactory.get({ path });
}
const end = performance.now();
console.log(`Created ${count} requests in ${end - start}ms`);
console.log(`Cache size: ${iconFactory.size()}`);
}
benchmark(100_000);
On a typical laptop the script finishes in ≈120 ms and reports a cache size of 3 (the three distinct emojis). Creating 100 000 separate objects without Flyweight would allocate hundreds of megabytes and take several seconds.
9. Extending the Pattern
- Composite Flyweights – For complex UI widgets (e.g., a card made of header, body, footer), each sub‑part can be a Flyweight and the composite returns a single React element tree.
- WeakMap cache – If you need automatic cleanup for rarely used icons, store instances in a
WeakMap<string, Instance>keyed by an object wrapper. - Typed Intrinsic Keys – Replace
JSON.stringifywith a custom hashing function (e.g., MurmurHash) for better performance when keys are large.
10. Conclusion
The Flyweight pattern, when combined with TypeScript’s strong typing, gives you a low‑overhead, reusable tool for building memory‑efficient UI components in React and Next.js. By:
- Separating intrinsic from extrinsic state,
- Caching immutable objects via a generic factory,
- Rendering with
React.cloneElementor simple wrappers,
you can safely render tens of thousands of UI elements while keeping the browser’s heap footprint small.
Apply this pattern to any scenario where many components share the same visual resources—icons, charts, map tiles, or even styled text fragments. The result is smoother scrolling, faster hydration, and a better user experience without sacrificing type safety or code readability.
Happy coding, and may your components stay lightweight!
Member discussion