Performance Optimization with dd<el>

As your applications grow, performance becomes increasingly important. dd<el> provides several techniques to optimize rendering performance, especially when dealing with large lists or frequently updating components. This guide focuses on memoization and other optimization strategies.

dd<el> Performance Optimization: Key Benefits

  • Efficient memoization system for component reuse
  • Targeted re-rendering without virtual DOM overhead
  • Memory management through AbortSignal integration
  • Optimized signal updates for reactive UI patterns
  • Simple debugging for performance bottlenecks
// use NPM or for example https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/esm-with-signals.js import { memo } from "deka-dom-el";

# Memoization with memo: Native vs dd<el>

In standard JavaScript applications, optimizing list rendering often involves manual caching or relying on complex virtual DOM diffing algorithms. dd<el>'s memo function provides a simpler, more direct approach:

Without Memoization
// Each update to todosArray recreates all elements function renderTodos(todosArray) { return el("ul").append( S.el(todosArray, todos => todos.map(todo=> el("li", { textContent: todo.text, dataState: todo.completed ? "completed" : "", }) )) ); }
With dd<el>'s memo
// With dd<el>’s memoization function renderTodos(todosArray) { return el("ul").append( S.el(todosArray, todos => todos.map(todo=> // Reuses DOM elements when items haven’t changed memo(todo.key, () => el("li", { textContent: todo.text, dataState: todo.completed ? "completed" : "", }) ))) ); }

The memo function in dd<el> allows you to cache and reuse DOM elements instead of recreating them on every render, which can significantly improve performance for components that render frequently or contain heavy computations.

The memo system is particularly useful for:

# Using memo with Signal Rendering

The most common use case for memoization is within S.el() when rendering lists with map():

S.el(todosSignal, todos => el("ul").append( ...todos.map(todo => // Use a unique identifiers memo(todo.id, () => el(TodoItem, todo) ))))

The memo function in this context:

  1. Takes a unique key (todo.id) to identify this item
  2. Caches the element created by the generator function
  3. Returns the cached element on subsequent renders if the key remains the same
  4. Only calls the generator function when rendering an item with a new key
// Example of how memoization improves performance with list rendering import { el, on, memo } from "./esm-with-signals.js"; import { S } from "./esm-with-signals.js"; // A utility to log element creation function logCreation(name) { console.log(`Creating ${name} element`); return name; } // Create a signal with our items const itemsSignal = S([ { id: 1, name: "Item 1" }, { id: 2, name: "Item 2" }, { id: 3, name: "Item 3" } ], { add() { const { length }= this.value; this.value.push({ id: length + 1, name: `Item ${length + 1}` }); }, force(){}, }); // Without memoization - creates new elements on every render function withoutMemo() { return el("div").append( el("h3", "Without Memoization (check console for element creation)"), el("p", "Elements are recreated on every render"), S.el(itemsSignal, items => el("ul").append( ...items.map(item => el("li").append( el("span", logCreation(item.name)) ) ) ) ), ); } // With memoization - reuses elements when possible function withMemo() { return el("div").append( el("h3", "With Memoization (check console for element creation)"), el("p", "Elements are reused when the key (item.id) stays the same"), S.el(itemsSignal, items => el("ul").append( ...items.map(item => // Use item.id as a stable key for memoization memo(item.id, () => el("li").append( el("span", logCreation(item.name)) ) ) ) ) ), ); } // Using memo.scope for a custom memoized function const renderMemoList = memo.scope(function(items) { return el("ul").append( ...items.map(item => memo(item.id, () => el("li").append( el("span", logCreation(`Custom memo: ${item.name}`)) ) ) ) ); }); function withCustomMemo() { return el("div").append( el("h3", "With Custom Memo Function"), el("p", "Using memo.scope to create a memoized rendering function"), S.el(itemsSignal, items => renderMemoList(items) ), el("button", "Clear Cache", on("click", () => { renderMemoList.clear(); S.action(itemsSignal, "force"); } ) ) ); } // Demo component showing the difference export function MemoDemo() { return el("div", { style: "padding: 1em; border: 1px solid #ccc;" }).append( el("h2", "Memoization Demo"), el("p", "See in the console when elements are created."), el("p").append(` Notice that without memoization, elements are recreated on every render. With memoization, only new elements are created. `), el("button", "Add Item", on("click", () => S.action(itemsSignal, "add")) ), el("div", { style: "display: flex; gap: 2em; margin-top: 1em;" }).append( withoutMemo(), withMemo(), withCustomMemo() ) ); } document.body.append(el(MemoDemo));

# Creating Memoization Scopes

The memo() uses cache store defined via the memo.scope function. That is actually what the S.el is doing under the hood:

import { memo } from "deka-dom-el"; // Create a memoization scope const renderItem = memo.scope(function(item) { return el().append( el("h3", item.title), el("p", item.description), // Expensive rendering operations... memo(item, ()=> el("div", { className: "expensive-component" })) ); }); // Use the memoized function const items = [/* array of items */]; const container = el("div").append( ...items.map(item => renderItem(item)) );

The scope function accepts options to customize its behavior:

const renderList = memo.scope(function(list) { return list.map(item => memo(item.id, () => el(ItemComponent, item)) ); }, { // Only keep the cache from the most recent render onlyLast: true, // Clear cache when signal is aborted signal: controller.signal });

You can use custom memo scope as function in (e. g. S.el(signal, renderList)) and as (Abort) signal use scope.signal.

onlyLast Option
Only keeps the cache from the most recent function call, which is useful when the entire collection is replaced. This is default behavior of S.el!
signal Option
An AbortSignal that will clear the cache when aborted, helping with memory management

# Additional Optimization Techniques

Minimizing Signal Updates

Signals are efficient, but unnecessary updates can impact performance:

Optimizing List Rendering

Beyond memoization, consider these approaches for optimizing list rendering:

Memoization works best when your keys are stable and unique. Use IDs or other persistent identifiers rather than array indices, which can change when items are reordered.

Alternatively you can use any “jsonable” value as key, when the primitive values aren’t enough.

Memory Management

To prevent memory leaks and reduce memory consumption:

Choosing the Right Optimization Approach

While memo is powerful, different scenarios call for different optimization techniques:

memo
Best for list rendering where items rarely change or only their properties update. todos.map(todo => memo(todo.id, () => el(TodoItem, todo))) Use when you need to cache and reuse DOM elements to avoid recreating them on every render.
Signal computations
Ideal for derived values that depend on other signals and need to auto-update. const totalPrice = S(() => items.get().reduce((t, i) => t + i.price, 0)) Use when calculated values need to stay in sync with changing source data.
Debouncing/Throttling
Essential for high-frequency events (scroll, resize) or rapidly changing input values. debounce(e => searchQuery.set(e.target.value), 300) Use to limit the rate at which expensive operations execute when triggered by fast events.
memo.scope
Useful for using memoization inside any function: const renderList = memo.scope(items => items.map(...)). Use to create isolated memoization contexts that can be cleared or managed independently.
Stateful components
For complex UI components with internal state management. el(ComplexComponent, { initialState, onChange }) Use when a component needs to encapsulate and manage its own state and lifecycle.

# Known Issues and Limitations

While memoization is a powerful optimization technique, there are some limitations and edge cases to be aware of:

Document Fragments and Memoization

One important limitation to understand is how memoization interacts with DocumentFragment objects. Functions like S.el internally use DocumentFragment to efficiently handle multiple elements, but this can lead to unexpected behavior with memoization.

// This pattern can lead to unexpected behavior const memoizedFragment = memo("key", () => { // Creates a DocumentFragment internally return S.el(itemsSignal, items => items.map(item => el("div", item))); }); // After the fragment is appended to the DOM, it becomes empty container.append(memoizedFragment); // On subsequent renders, the cached fragment is empty! container.append(memoizedFragment); // Nothing gets appended

This happens because a DocumentFragment is emptied when it's appended to the DOM. When the fragment is cached by memo and reused, it's already empty.

Solution: Memoize Individual Items
// Correct approach: memoize the individual items, not the fragment S.el(itemsSignal, items => items.map(item => memo(item.id, () => el("div", item)) )); // Or use a container element instead of relying on a fragment memo("key", () => el("div", { className: "item-container" }).append( S.el(itemsSignal, items => items.map(item => el("div", item))) ) );

Generally, you should either:

  1. Memoize individual items within the collection, not the entire collection result
  2. Wrap the result in a container element instead of relying on fragment behavior
  3. Be aware that S.el() and similar functions that return multiple elements are using fragments internally

This limitation isn't specific to dd<el> but is related to how DocumentFragment works in the DOM. Once a fragment is appended to the DOM, its child nodes are moved from the fragment to the target element, leaving the original fragment empty.

# Performance Debugging

To identify performance bottlenecks in your dd<el> applications:

  1. Use browser performance tools to profile rendering times
  2. Check for excessive signal updates using S.on() listeners with console.log
  3. Verify memo usage by inspecting cache hit rates
  4. Look for components that render more frequently than necessary

For more details on debugging, see the Debugging page.

# Best Practices for Optimized Rendering

  1. Use memo for list items: Memoize items in lists, especially when they contain complex components.
  2. Clean up with AbortSignals: Connect memo caches to component lifecycles using AbortSignals.
  3. Profile before optimizing: Identify actual bottlenecks before adding optimization.
  4. Use derived signals: Compute derived values efficiently with signal computations.
  5. Avoid memoizing fragments: Memoize individual elements or use container elements instead of DocumentFragments.

# Mnemonic

  • memo.scope(<function>, <argument(s)>) — Scope for memo
  • memo(<key>, <generator>) — returns value from memo and/or generates it (and caches it)