Disposer
Collect owner-local cleanup work and unwind it safely in reverse order.
Demo
Register cleanup tasks, then run the stack to reveal the order in which they are called.
The cleanup stack is empty.
'use client'
import { Disposer } from '@joycostudio/xyz'
import { useEffect, useRef, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Cluster } from '@/components/ui/cluster'
import { DemoPanel } from '@/components/demos/demo-ui'
function ordinal(value: number) {
const remainder = value % 100
if (remainder >= 11 && remainder <= 13) return `${value}th`
if (value % 10 === 1) return `${value}st`
if (value % 10 === 2) return `${value}nd`
if (value % 10 === 3) return `${value}rd`
return `${value}th`
}
export default function DisposerDemo() {
const disposer = useRef(new Disposer())
const [executed, setExecuted] = useState<number[]>([])
const [count, setCount] = useState(0)
const [disposed, setDisposed] = useState(false)
useEffect(() => () => disposer.current.dispose(), [])
const reset = () => {
disposer.current.dispose()
disposer.current = new Disposer()
setExecuted([])
setCount(0)
setDisposed(false)
}
const add = () => {
const id = count + 1
disposer.current.add(() => setExecuted((value) => [...value, id]))
setCount(id)
}
return (
<DemoPanel description="Register cleanup tasks, then run the stack to reveal the order in which they are called.">
<Cluster wrap>
<Button disabled={disposed} onClick={add}>
Register cleanup #{count + 1}
</Button>
<Button
variant="secondary"
disabled={disposed || count === 0}
onClick={() => {
disposer.current.dispose()
setDisposed(true)
}}
>
Dispose
</Button>
<Button variant="ghost" onClick={reset}>
Reset
</Button>
</Cluster>
{count === 0 ? (
<p className="mt-5 bg-muted px-3 py-4 text-sm text-muted-foreground">The cleanup stack is empty.</p>
) : (
<Cluster asChild direction="col" align="stretch" className="mt-5" bg="accent">
<ol aria-label="Cleanup stack, newest registration first">
{Array.from({ length: count }, (_, index) => {
const id = count - index
const executionIndex = executed.indexOf(id)
return (
<Cluster key={id} asChild align="stretch" bg={executionIndex === -1 ? 'muted' : 'accent'}>
<li>
<Badge variant="card" slicedCorners={false}>
#{String(id).padStart(2, '0')}
</Badge>
<code className="flex min-w-0 flex-1 items-center px-3 py-2 font-mono text-sm">cleanup {id}</code>
<Badge
variant={executionIndex === -1 ? 'muted' : 'default'}
className={executionIndex === -1 ? 'animate-pulse' : undefined}
>
{executionIndex === -1 ? 'waiting' : `called ${ordinal(executionIndex + 1)}`}
</Badge>
</li>
</Cluster>
)
})}
</ol>
</Cluster>
)}
</DemoPanel>
)
}
Usage
import { Disposer } from '@joycostudio/xyz'
const cleanup = new Disposer()
cleanup.listen(window, 'resize', () => console.log(window.innerWidth))
const timer = setInterval(() => console.log('tick'), 1000)
cleanup.add(() => clearInterval(timer))
// When the owner is removed: stop the timer, then remove the listener.
cleanup.dispose()
API
new Disposer
new Disposer()
Creates an owner-local cleanup stack. Takes no arguments.
add
cleanup.add<T extends TeardownInput>(teardown: T): T
Registers cleanup and returns the original argument unchanged. Cleanups added after disposal run immediately.
| Argument | Description |
|---|---|
teardown | A cleanup function, an object with dispose(), or a readonly array of either. Arrays register left to right and dispose right to left. |
const cleanup = new Disposer()
const timer = setInterval(() => console.log('tick'), 1000)
cleanup.add(() => clearInterval(timer))
listen
cleanup.listen<T extends Event>(
target: EventTarget,
type: string,
listener: (event: T) => void,
options?: AddEventListenerOptions | boolean
): void
Adds an event listener and registers its removal with the same options.
| Argument | Description |
|---|---|
target | The EventTarget to listen on, such as window or an element. |
type | Event name, such as 'resize'. |
listener | Callback receiving the event. Supply a type argument such as KeyboardEvent when you need event-specific properties. |
options? | Standard addEventListener options or a boolean capture flag. Passed through unchanged. |
const cleanup = new Disposer()
cleanup.listen<KeyboardEvent>(
window,
'keydown',
(event) => {
console.log(event.code)
},
{ capture: true }
)
disposed
cleanup.disposed // boolean
Read-only flag indicating whether disposal has begun.
dispose
cleanup.dispose(): void
Runs all cleanups once in reverse registration order. Takes no arguments; subsequent calls do nothing. Cleanup continues after failures: one failure is rethrown, while multiple failures become an AggregateError.