Coreclass

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.

ArgumentDescription
teardownA 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.

ArgumentDescription
targetThe EventTarget to listen on, such as window or an element.
typeEvent name, such as 'resize'.
listenerCallback 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.