Reactive frameworks must remain the owners of DOM nodes they render. Use the detach option to update framework state instead of letting Vanilla Disintegrate call element.remove() itself.
effects.remove(element, {
effect: 'dust',
detach: () => removeFromFrameworkState(),
});
The hook runs after the element has been measured and captured, exactly when removal is committed. If detach is omitted, the library uses element.remove() as usual.
For restoration, render the element first, wait until the framework has committed it to the DOM, and then call restore() with the new element reference. Framework state should store application data—not a retained DOM node—so reactive integrations normally leave retain at its default false.
Because this is a new DOM node, pass the same effect to restore() explicitly when removal did not use the instance default. Remembered effect metadata follows only the same retained DOM node.
React
React needs a synchronous state commit because its renderer owns the target node. flushSync() makes that boundary explicit. useLayoutEffect() starts restoration after React has inserted and measured the new node.
'use client';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import Disintegrator from 'vanilla-disintegrate/snapdom';
export function DisintegratingCard() {
const [visible, setVisible] = useState(true);
const cardRef = useRef<HTMLElement | null>(null);
const effectsRef = useRef<Disintegrator | null>(null);
const pendingRestore = useRef(false);
useEffect(() => {
const effects = new Disintegrator();
effectsRef.current = effects;
return () => {
effects.destroy();
effectsRef.current = null;
};
}, []);
useLayoutEffect(() => {
const effects = effectsRef.current;
const element = cardRef.current;
if (!visible || !pendingRestore.current || !effects || !element) return;
pendingRestore.current = false;
effects.restore(element, { effect: 'dust' });
}, [visible]);
function removeCard() {
const effects = effectsRef.current;
const element = cardRef.current;
if (!effects || !element) return;
effects.remove(element, {
effect: 'dust',
detach: () => flushSync(() => setVisible(false)),
});
}
function restoreCard() {
pendingRestore.current = true;
setVisible(true);
}
return visible ? (
<article ref={cardRef}>
<h2>Activity</h2>
<button onClick={removeCard}>Remove</button>
</article>
) : (
<button onClick={restoreCard}>Restore</button>
);
}
Create the instance in an effect and destroy it in cleanup. This also behaves correctly under React Strict Mode. In Next.js or another React SSR framework, keep the integration in a client component.
Vue
Vue batches the state change before the browser paints. nextTick() gives restore() the newly rendered element and its final geometry.
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import Disintegrator from 'vanilla-disintegrate/snapdom';
const visible = ref(true);
const card = ref<HTMLElement | null>(null);
let effects: Disintegrator | null = null;
onMounted(() => {
effects = new Disintegrator();
});
onUnmounted(() => {
effects?.destroy();
});
function removeCard() {
if (!effects || !card.value) return;
effects.remove(card.value, {
effect: 'dust',
detach: () => {
visible.value = false;
},
});
}
async function restoreCard() {
visible.value = true;
await nextTick();
if (effects && card.value) {
effects.restore(card.value, { effect: 'dust' });
}
}
</script>
<template>
<article v-if="visible" ref="card">
<h2>Activity</h2>
<button @click="removeCard">Remove</button>
</article>
<button v-else @click="restoreCard">Restore</button>
</template>
The same component works in Nuxt because browser-only setup lives in onMounted().
Svelte
Use bind:this for the live node, return destroy() from onMount(), and await tick() before restoration.
<script lang="ts">
import { onMount, tick } from 'svelte';
import Disintegrator from 'vanilla-disintegrate/snapdom';
let visible = true;
let card: HTMLElement;
let effects: Disintegrator | null = null;
onMount(() => {
effects = new Disintegrator();
return () => effects?.destroy();
});
function removeCard() {
if (!effects || !card) return;
effects.remove(card, {
effect: 'dust',
detach: () => {
visible = false;
},
});
}
async function restoreCard() {
visible = true;
await tick();
if (effects && card) {
effects.restore(card, { effect: 'dust' });
}
}
</script>
{#if visible}
<article bind:this={card}>
<h2>Activity</h2>
<button onclick={removeCard}>Remove</button>
</article>
{:else}
<button onclick={restoreCard}>Restore</button>
{/if}
This lifecycle is also SSR-safe in SvelteKit: onMount() runs only in the browser.
Solid
Solid updates a signal-owned branch synchronously. A microtask after setVisible(true) ensures restoration sees the inserted node.
import { createSignal, onCleanup, onMount, Show } from 'solid-js';
import Disintegrator from 'vanilla-disintegrate/snapdom';
export function DisintegratingCard() {
const [visible, setVisible] = createSignal(true);
let card!: HTMLElement;
let effects: Disintegrator | null = null;
onMount(() => {
effects = new Disintegrator();
});
onCleanup(() => effects?.destroy());
function removeCard() {
if (!effects || !card) return;
effects.remove(card, {
effect: 'dust',
detach: () => setVisible(false),
});
}
function restoreCard() {
setVisible(true);
queueMicrotask(() => effects?.restore(card, { effect: 'dust' }));
}
return (
<Show when={visible()} fallback={<button onClick={restoreCard}>Restore</button>}>
<article ref={card}>
<h2>Activity</h2>
<button onClick={removeCard}>Remove</button>
</article>
</Show>
);
}
Use the same client lifecycle in SolidStart.
Angular
With signals, Angular can commit the conditional view inside detach. afterNextRender() keeps construction out of server rendering, and DestroyRef owns cleanup.
import {
afterNextRender,
ChangeDetectorRef,
Component,
DestroyRef,
ElementRef,
inject,
signal,
viewChild,
} from '@angular/core';
import Disintegrator from 'vanilla-disintegrate/snapdom';
@Component({
selector: 'app-disintegrating-card',
standalone: true,
template: `
@if (visible()) {
<article #card>
<h2>Activity</h2>
<button (click)="removeCard()">Remove</button>
</article>
} @else {
<button (click)="restoreCard()">Restore</button>
}
`,
})
export class DisintegratingCard {
readonly visible = signal(true);
readonly card = viewChild<ElementRef<HTMLElement>>('card');
private readonly changeDetector = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private effects: Disintegrator | null = null;
constructor() {
afterNextRender(() => {
this.effects = new Disintegrator();
});
this.destroyRef.onDestroy(() => this.effects?.destroy());
}
removeCard() {
const element = this.card()?.nativeElement;
if (!this.effects || !element) return;
this.effects.remove(element, {
effect: 'dust',
detach: () => {
this.visible.set(false);
this.changeDetector.detectChanges();
},
});
}
restoreCard() {
this.visible.set(true);
this.changeDetector.detectChanges();
const element = this.card()?.nativeElement;
if (this.effects && element) {
this.effects.restore(element, { effect: 'dust' });
}
}
}
Any UI with DOM
Vanilla Disintegrate works in any browser UI with measurable DOM elements. It does not need a framework, an adapter, or a separate integration package.
React, Vue, Svelte, Solid, and Angular above are not a list of supported platforms. They are examples of their typical lifecycle patterns. The same approach works with any other framework, library, or plain JavaScript application.
The examples use each framework’s documented DOM and lifecycle APIs: React refs and effects, Vue template refs and lifecycle, Svelte lifecycle, Solid refs and cleanup, and Angular component lifecycle.