DEV Community

glutio
glutio

Posted on

Persistent Component State Across DOM Reparenting in Mithril.js

When building complex UIs with dockable panels, modal dialogs, or drag-and-drop interfaces, you often need to move components between different DOM parents. In Mithril.js, this creates a problem: moving a component to a different parent destroys the component instance and loses all local state, even when using the same key.

The Problem

// Before: component in sidebar
m('div.sidebar', [
    m(MyComponent, { key: 'editor-1' })  // Has local state
]);

// After: component moved to floating window
m('div.floating-window', [
    m(MyComponent, { key: 'editor-1' })  // NEW instance, lost state!
]);
Enter fullscreen mode Exit fullscreen mode

Even with identical keys, Mithril treats this as component removal + creation because the parent changed. The key attribute only helps with reordering within the same parent.

The Solution: Reference-Counted State Store

The key insight is that during reparenting, both the old and new component instances with the same key briefly exist simultaneously. We can use this overlap to detect reparenting vs. true removal.

How It Works

During reparenting (same key, different parent):

  1. Old component instance exists → refCount = 1
  2. New component instance created with same key → refCount = 2
  3. Old component instance removed → refCount = 1, state preserved
  4. New component instance continues with restored state

During true removal:

  1. Component instance exists → refCount = 1
  2. Component instance removed, no replacement → refCount = 0, state cleaned up

Example: Persistent Counter

class Counter {
    static stateStore = new Map();

    oninit(vnode) {
        const key = vnode.key;

        // Get or create state entry
        let entry = Counter.stateStore.get(key);
        if (!entry) {
            entry = { 
                refCount: 0, 
                state: { count: 0 }
            };
            Counter.stateStore.set(key, entry);
        }

        entry.refCount++;
        this.persistentState = entry.state;
    }

    onbeforeremove(vnode) {
        const key = vnode.key;
        const entry = Counter.stateStore.get(key);

        if (entry) {
            entry.refCount--;
            if (entry.refCount === 0) {
                Counter.stateStore.delete(key);
            }
        }
    }

    view() {
        return m('div', [
            m('p', `Count: ${this.persistentState.count}`),
            m('button', { 
                onclick: () => this.persistentState.count++ 
            }, 'Increment')
        ]);
    }
}

// Usage - counter value survives reparenting between different containers
m(Counter, { key: 'my-counter' });
Enter fullscreen mode Exit fullscreen mode

Benefits

  • Automatic: Works with existing key attributes
  • Memory Safe: Reference counting prevents leaks
  • Framework Native: Uses only Mithril's lifecycle hooks
  • Simple: Just add the pattern to any component that needs it

Conclusion

This pattern fills a gap in Mithril's component lifecycle, enabling sophisticated UI behaviors like undockable panels and persistent modal dialogs. The reference counting approach is elegant because it leverages Mithril's own timing - the brief moment when both the old vnode and the new vnode exist during reparenting becomes our signal to preserve state.

Top comments (0)