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:
- Lists that update frequently but where most items remain the same
- Components with expensive rendering operations
- Optimizing signal-driven UI updates
# 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:
- Takes a unique key (todo.id) to identify this item
- Caches the element created by the generator function
- Returns the cached element on subsequent renders if the key remains the same
- 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:
- For frequently updating values (like scroll position), consider debouncing
- Keep signal computations small and focused
Optimizing List Rendering
Beyond memoization, consider these approaches for optimizing list rendering:
- Virtualize long lists to only render visible items
- Use stable, unique keys for list items
- Batch updates to signals that drive large lists
- Consider using a memo scope for the entire list component
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:
- Clear memo caches when components are removed
- Use AbortSignals to manage memo lifetimes
- Call S.clear() on signals that are no longer needed
- Remove event listeners when elements are removed from the DOM
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:
- Memoize individual items within the collection, not the entire collection result
- Wrap the result in a container element instead of relying on fragment behavior
- 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:
- Use browser performance tools to profile rendering times
- Check for excessive signal updates using S.on() listeners with console.log
- Verify memo usage by inspecting cache hit rates
- Look for components that render more frequently than necessary
For more details on debugging, see the Debugging page.
# Best Practices for Optimized Rendering
- Use memo for list items: Memoize items in lists, especially when they contain complex components.
- Clean up with AbortSignals: Connect memo caches to component lifecycles using AbortSignals.
- Profile before optimizing: Identify actual bottlenecks before adding optimization.
- Use derived signals: Compute derived values efficiently with signal computations.
- Avoid memoizing fragments: Memoize individual elements or use container elements instead of DocumentFragments.