DOM Morphing
DOM morphing is the intelligent process of updating your page's HTML while preserving JavaScript state, event listeners, focus, and user selections. Instead of destroying and recreating elements, Hyper uses Datastar's morphing algorithm to surgically update only what changed, creating a smooth, flicker-free experience.
Why Morphing Matters
Morphing preserves the "life" in your page - things like focus state, scroll positions, CSS transitions, and third-party widget state. Without morphing, every update would feel like a hard page refresh.
The Problem with Replacement
Traditional DOM replacement destroys everything and starts fresh:
// Server returns new HTML
return hyper()->replace('#list', '<div id="list">New content</div>');What gets lost:
- Active input focus
- Form input values not synced with signals
- Scroll positions
- CSS transition states
- Event listeners from third-party libraries
- Video/audio playback position
The user experience:
- Flickers during update
- Focus jumps unexpectedly
- Scrolls reset to top
- Animations restart abruptly
The Solution: Morphing
Morphing compares the old and new HTML trees and surgically updates only what changed:
// Morphing preserves state while updating content
return hyper()->outer('#list', '<div id="list">New content</div>');What gets preserved:
- ✅ Input focus (cursor stays in the same field)
- ✅ Form values (partially filled forms don't reset)
- ✅ Scroll positions (list scrolling maintained)
- ✅ Event listeners (click handlers stay attached)
- ✅ CSS transitions (smooth animations continue)
- ✅ Third-party widgets (maps, charts, editors keep state)
How Morphing Works
Hyper uses Datastar's idiomorph algorithm, an intelligent DOM diffing strategy:
1. Element Matching
The algorithm identifies corresponding elements between old and new trees using:
ID Matching (Strongest):
<!-- Old HTML -->
<div id="user-123">John Doe</div>
<!-- New HTML -->
<div id="user-123">John Smith</div>
<!-- Result: Updates text, preserves element -->Position + Tag Matching:
<!-- Old HTML -->
<ul>
<li>First</li>
<li>Second</li>
</ul>
<!-- New HTML -->
<ul>
<li>First Modified</li>
<li>Second</li>
</ul>
<!-- Result: Updates first li's text, preserves both elements -->2. Attribute Diffing
Only changed attributes are updated:
<!-- Old -->
<button class="btn" disabled>Loading...</button>
<!-- New -->
<button class="btn btn-primary">Submit</button>
<!-- Result: Adds btn-primary class, removes disabled attribute -->3. Content Updating
Text content is updated without recreating the element:
<!-- Old -->
<span>Loading...</span>
<!-- New -->
<span>Complete!</span>
<!-- Result: Text changes, span element reused -->4. Structural Changes
When structure changes, only affected nodes are recreated:
<!-- Old -->
<div>
<p>Paragraph 1</p>
<p>Paragraph 2</p>
</div>
<!-- New -->
<div>
<p>Paragraph 1</p>
<h2>New Heading</h2>
<p>Paragraph 2</p>
</div>
<!-- Result: Paragraph elements preserved, h2 inserted between them -->Morphing Modes
Hyper provides different morphing modes for different use cases:
outer (Default)
Morphs the entire element including its tag:
return hyper()->fragment('posts.list', 'post-list', $data);
// or
return hyper()->outer('#post-list', $html);<!-- Old -->
<div id="post-list" class="loading">
<p>Loading...</p>
</div>
<!-- New -->
<div id="post-list" class="loaded">
<ul>...</ul>
</div>
<!-- Result: div attributes and contents morph -->Use when:
- Updating a component entirely
- Element attributes might change
- Default for fragments
inner
Morphs only the element's children, preserving the container:
return hyper()->inner('#container', $html);<!-- Old -->
<div id="container" class="wrapper">
<p>Old content</p>
</div>
<!-- New HTML passed to inner: -->
<p>New content</p>
<!-- Result: Container div preserved, children replaced -->
<div id="container" class="wrapper">
<p>New content</p>
</div>Use when:
- Container element should never change
- Only updating content, not wrapper
- Preserving container's event listeners
replace
Completely destroys and recreates the element:
return hyper()->replace('#widget', $html);Use when:
- Morphing is causing issues
- Need a clean slate (third-party widget re-initialization)
- Structure changed too drastically for morphing
Performance Cost
replace mode loses all preserved state. Use sparingly.
morph (Explicit Outer)
Explicitly use outer morphing mode:
return hyper()->outer('#element', $html);append / prepend
Add new content without replacing existing:
// Add to end
return hyper()->append('#list', '<li>New item</li>');
// Add to beginning
return hyper()->prepend('#list', '<li>New item</li>');Use for:
- Infinite scroll (append new items)
- Chat messages (prepend new messages)
- Activity feeds
before / after
Insert content as siblings:
// Insert before element
return hyper()->before('#target', $html);
// Insert after element
return hyper()->after('#target', $html);delete
Remove the element:
return hyper()->remove('#notification');When Morphing Succeeds
Morphing works best when:
1. Element IDs Are Stable
<!-- ✅ Good - Stable IDs -->
<div id="user-{{ $user->id }}">
{{ $user->name }}
</div>
<!-- ❌ Poor - Changing IDs -->
<div id="user-{{ rand() }}">
{{ $user->name }}
</div>2. Structure Is Consistent
<!-- ✅ Good - Same structure -->
<!-- Before: -->
<ul id="list">
<li>Item 1</li>
<li>Item 2</li>
</ul>
<!-- After: -->
<ul id="list">
<li>Item 1 Updated</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
<!-- ❌ Poor - Radical structure change -->
<!-- Before: -->
<ul id="list"><li>Items</li></ul>
<!-- After: -->
<table id="list"><tr><td>Items</td></tr></table>3. Using data-for with Keys
<!-- ✅ Good - Stable keys help morphing -->
<template data-for="user in $users" data-for__key="id">
<div id="user-{user.id}">
<span data-text="user.name"></span>
</div>
</template>
<!-- ❌ Poor - No keys, index-based matching -->
<template data-for="user in $users">
<div><span data-text="user.name"></span></div>
</template>4. Attributes Change Predictably
<!-- ✅ Good - Predictable attribute changes -->
<button class="btn" data-attr:class="$loading ? 'btn loading' : 'btn'">
Submit
</button>
<!-- ⚠️ Watch out - Unpredictable classes -->
<button class="{{ collect(['btn', $classes, $moreClasses])->flatten()->unique()->implode(' ') }}">When Morphing Struggles
Radical Structure Changes
<!-- Old: List structure -->
<div id="content">
<ul><li>Item</li></ul>
</div>
<!-- New: Table structure -->
<div id="content">
<table><tr><td>Item</td></tr></table>
</div>
<!-- Result: May experience flicker or state loss -->Solution: Use replace mode for radical changes, or keep structure consistent.
Changing Element Types
<!-- Old -->
<div id="wrapper">
<span>Text</span>
</div>
<!-- New -->
<div id="wrapper">
<p>Text</p>
</div>
<!-- Result: span → p requires recreation -->Solution: Keep element types consistent when possible.
Dynamic IDs
<!-- ❌ Poor - ID changes on every render -->
<div id="item-{{ time() }}">
<!-- ✅ Better - Stable ID -->
<div id="item-{{ $item->id }}">Complex Third-Party Widgets
Some widgets modify their DOM internally in ways morphing can't track:
<!-- Chart library that manipulates DOM -->
<div id="chart"></div>
<script>
new Chart('#chart', {...});
</script>
<!-- After morph: Chart might break -->Solution: Use data-ref and reinitialize after updates, or use replace mode.
Debugging Morphing
Visualizing Changes
Add this to see what's being updated:
// In browser console
// Watch for morphing events (if Datastar exposes them)
document.addEventListener('datastar-morph', (e) => {
console.log('Morphed:', e.detail);
});Comparing HTML
Log before/after HTML to understand what changed:
public function update()
{
$html = view('components.list', $data)->render();
Log::info('Morphing HTML:', ['html' => $html]);
return hyper()->html($html, ['selector' => '#list']);
}Using Browser DevTools
- Open DevTools → Elements
- Trigger a Hyper update
- Watch for flash highlighting (shows changed elements)
- Inspect element structure before/after
Check for ID Stability
@fragment('users')
<div id="users">
@foreach($users as $user)
<div id="user-{{ $user->id }}"
data-debug="{{ json_encode(['id' => $user->id, 'name' => $user->name]) }}">
{{ $user->name }}
</div>
@endforeach
</div>
@endfragmentCommon Patterns
Preserving Form Input Focus
<div id="search-results">
<input type="text"
id="search-input"
data-bind="query"
data-on:input__debounce.300ms="@get('/search')" />
<div id="results">
@foreach($results as $result)
<div id="result-{{ $result->id }}">{{ $result->title }}</div>
@endforeach
</div>
</div>The #search-input retains focus even as results update because morphing preserves it.
Infinite Scroll with Morphing
<div id="items">
@foreach($items as $item)
<div id="item-{{ $item->id }}">{{ $item->name }}</div>
@endforeach
</div>
<div data-on-intersect__once="@get('/load-more')">Load more...</div>public function loadMore()
{
$moreItems = Item::paginate(20, ['*'], 'page', request('page', 1));
// Append new items without affecting existing ones
$html = view('partials.items', ['items' => $moreItems])->render();
return hyper()->append('#items', $html);
}Updating Nested Components
<div id="dashboard">
@fragment('stats')
<div id="stats">
<div id="stat-users">{{ $stats['users'] }}</div>
<div id="stat-revenue">${{ $stats['revenue'] }}</div>
</div>
@endfragment
@fragment('chart')
<div id="chart">
<canvas id="revenue-chart"></canvas>
</div>
@endfragment
</div>// Update only stats without touching chart
return hyper()->fragment('dashboard', 'stats', ['stats' => $newStats]);The canvas in #chart is untouched, preserving any Chart.js instance.
Form Validation with Preserved State
<form id="signup" @signals(['name' => '', 'email' => '', 'errors' => []])
data-on:submit__prevent="@postx('/signup')">
<input type="text" id="name-input" data-bind="name" />
<div data-error="name"></div>
<input type="email" id="email-input" data-bind="email" />
<div data-error="email"></div>
<button type="submit">Sign Up</button>
</form>When validation fails and returns errors, morphing preserves:
- Current input focus
- Partially typed values
- Cursor position
Performance Considerations
Morphing is Fast
The idiomorph algorithm is highly optimized:
Typical benchmarks:
- Small fragment (10 elements): <1ms
- Medium list (100 items): 2-5ms
- Large update (500 elements): 10-20ms
These are faster than human perception (~16ms per frame).
When to Use Replace
Only use replace mode when:
- You measured a performance problem with morphing
- Morphing is causing visual glitches
- You need to reinitialize third-party widgets
Batch Updates
Update multiple fragments in one response:
return hyper()->fragments([
['view' => 'dashboard', 'fragment' => 'stats', 'data' => $stats],
['view' => 'dashboard', 'fragment' => 'activity', 'data' => $activity],
]);This is more efficient than separate requests.
Comparison with Other Approaches
vs Virtual DOM (React, Vue)
| Feature | Morphing (idiomorph) | Virtual DOM |
|---|---|---|
| Approach | Direct DOM comparison | In-memory tree comparison |
| Performance | Excellent (native) | Good (JS overhead) |
| State preservation | Automatic | Requires keys/refs |
| Third-party widgets | Preserved | Often break |
| Learning curve | None | Requires framework knowledge |
vs innerHTML Replacement
| Feature | Morphing | innerHTML |
|---|---|---|
| State preservation | ✅ Yes | ❌ No |
| Event listeners | ✅ Preserved | ❌ Lost |
| Focus | ✅ Maintained | ❌ Lost |
| Scroll position | ✅ Kept | ❌ Reset |
| Performance | Excellent | Good |
Learn More
- Fragments - Using fragments with morphing
- HyperResponse - Complete API for patchElements
- Lists & Loops - Using data-for with morphing
- Idiomorph Documentation - Morphing algorithm details

