Skip to content

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:

php
// 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:

php
// 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):

html
<!-- 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:

html
<!-- 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:

html
<!-- 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:

html
<!-- 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:

html
<!-- 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:

php
return hyper()->fragment('posts.list', 'post-list', $data);
// or
return hyper()->outer('#post-list', $html);
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:

php
return hyper()->inner('#container', $html);
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:

php
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:

php
return hyper()->outer('#element', $html);

append / prepend

Add new content without replacing existing:

php
// 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:

php
// Insert before element
return hyper()->before('#target', $html);

// Insert after element
return hyper()->after('#target', $html);

delete

Remove the element:

php
return hyper()->remove('#notification');

When Morphing Succeeds

Morphing works best when:

1. Element IDs Are Stable

blade
<!-- ✅ 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

blade
<!-- ✅ 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

blade
<!-- ✅ 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

blade
<!-- ✅ 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

html
<!-- 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

html
<!-- 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

blade
<!-- ❌ 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:

html
<!-- 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:

javascript
// 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:

php
public function update()
{
    $html = view('components.list', $data)->render();

    Log::info('Morphing HTML:', ['html' => $html]);

    return hyper()->html($html, ['selector' => '#list']);
}

Using Browser DevTools

  1. Open DevTools → Elements
  2. Trigger a Hyper update
  3. Watch for flash highlighting (shows changed elements)
  4. Inspect element structure before/after

Check for ID Stability

blade
@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>
@endfragment

Common Patterns

Preserving Form Input Focus

blade
<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

blade
<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>
php
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

blade
<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>
php
// 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

blade
<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:

  1. You measured a performance problem with morphing
  2. Morphing is causing visual glitches
  3. You need to reinitialize third-party widgets

Batch Updates

Update multiple fragments in one response:

php
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)

FeatureMorphing (idiomorph)Virtual DOM
ApproachDirect DOM comparisonIn-memory tree comparison
PerformanceExcellent (native)Good (JS overhead)
State preservationAutomaticRequires keys/refs
Third-party widgetsPreservedOften break
Learning curveNoneRequires framework knowledge

vs innerHTML Replacement

FeatureMorphinginnerHTML
State preservation✅ Yes❌ No
Event listeners✅ Preserved❌ Lost
Focus✅ Maintained❌ Lost
Scroll position✅ Kept❌ Reset
PerformanceExcellentGood

Learn More