Skip to content
Back to skills

Using Kea Disposables

ASecurity

Use when adding timers (`setInterval`, `setTimeout`), event listeners (`window.addEventListener`, `document.addEventListener`, `MediaQueryList.addEventListener`), or any other resource that needs cleanup inside a kea logic. Every logic has `cache.disposables.add(setup, key?, options?)` and `cache.disposables.dispose(key)` available via the globally registered `disposablesPlugin` from the `kea-disposables` package. Replaces the bare `cache.foo = setInterval(...)` + `beforeUnmount: clearInterva...

  • 40,048 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 1, 2026
developmentgogitfrontend

Works with

  • cli

Security analysis

A100/100

Scanned September 20, 2026

npx -y skills add PostHog/posthog --skill using-kea-disposables --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Using Kea Disposables?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Using Kea Disposables
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/posthog-using-kea-disposables-posthog/badge)](https://www.skillsdirectory.com/skills/posthog-using-kea-disposables-posthog)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: using-kea-disposables
description: 'Use when adding timers (`setInterval`, `setTimeout`), event listeners (`window.addEventListener`, `document.addEventListener`, `MediaQueryList.addEventListener`), or any other resource that needs cleanup inside a kea logic. Every logic has `cache.disposables.add(setup, key?, options?)` and `cache.disposables.dispose(key)` available via the globally registered `disposablesPlugin` from the `kea-disposables` package. Replaces the bare `cache.foo = setInterval(...)` + `beforeUnmount: clearInterval(cache.foo)` pattern and auto-pauses background work when the tab is hidden.'
---

# Using kea disposables

Every kea logic in this repo has `cache.disposables` injected by `disposablesPlugin` from the [`kea-disposables`](https://github.com/PostHog/kea-disposables) package, registered globally in `frontend/src/initKea.ts` and in `frontend/src/toolbar/index.tsx`. Reach for it whenever you create a resource that needs explicit teardown — the plugin runs cleanup on unmount and automatically pauses background work when the tab is hidden.

The package is maintained outside this repo, so a change to the plugin itself belongs there, not here. Types come from the package too: `import type { DisposablesManager } from 'kea-disposables'`.

**Do not add a `beforeUnmount` for cleanup.** The plugin runs the cleanup function you return from `setup` automatically when the logic unmounts (and re-runs setup/cleanup around tab visibility changes). If you find yourself writing a `beforeUnmount` whose only job is to `clearInterval` / `clearTimeout` / `removeEventListener` something registered earlier in the same logic, register that resource through `cache.disposables.add(...)` instead and delete the `beforeUnmount`. Reserve `beforeUnmount` for teardown that _isn't_ a resource you control (e.g. flushing state, persisting to localStorage, calling a third-party `dispose()`).

## Use this skill when

- Adding `setInterval` or `setTimeout` inside `afterMount`, a listener, or a subscription
- Adding `window.addEventListener`, `document.addEventListener`, or `MediaQueryList.addEventListener`
- Adding any subscription that needs explicit teardown (WebSocket, EventSource, ResizeObserver, IntersectionObserver, etc.)
- Reviewing or editing a logic with a bare `cache.<thing>` plus a matching `beforeUnmount` cleanup — convert it
- A state change should tear down a previously-registered timer or listener early

## The pattern

```ts
cache.disposables.add(
    setup,    // () => () => void — runs immediately; MUST return a cleanup function
    key?,     // string — re-adding with the same key disposes the previous one first
    options?, // { pauseOnPageHidden?: boolean } — default true: cleanup runs on hide, setup re-runs on show
)
```

Canonical example (`frontend/src/layout/navigation/noEventsBannerLogic.ts:14-21`):

```ts
afterMount(({ actions, cache }) => {
    cache.disposables.add(() => {
        const pollTimer = window.setInterval(() => {
            actions.loadCurrentTeam()
        }, POLL_INTERVAL_MS)
        return () => clearInterval(pollTimer)
    })
}),
```

For a resource that lives exactly as long as the logic, the `disposables` builder replaces that `afterMount`:

```ts
import { disposables } from 'kea-disposables'

disposables(({ actions }) => ({
    pollTimer: () => {
        const id = window.setInterval(() => actions.loadCurrentTeam(), POLL_INTERVAL_MS)
        return () => clearInterval(id)
    },
})),
```

The object keys are ordinary disposable keys, so `dispose('pollTimer')` still stops it early.
Pass `{ setup, options }` instead of a bare function to set `pauseOnPageHidden`.
Anything conditional, or re-armed from a listener, still wants `cache.disposables.add(...)`.

## Choosing a key

- **No key** — fire-and-forget; cleaned up only on unmount. Fine for one-shot listeners registered in `afterMount`.
- **Named key** — needed when:
  - You'll call `cache.disposables.dispose(key)` later to stop it early
  - The same setup may be re-added and each call should replace the previous one (spam-replacement)

## `pauseOnPageHidden`

The default (`true`) is correct for almost everything — polling, animation tickers, hover timers. Background tabs stop doing work and resume on focus, which dramatically reduces CPU and network cost.

Opt out (`{ pauseOnPageHidden: false }`) only when the listener must keep firing while the page is hidden:

- Listeners for events that can genuinely fire while the tab is hidden — e.g. `storage` (writes from another tab), `online` / `offline`, `message` (from web workers, service workers, or other windows)
- A `visibilitychange` listener itself — the whole point is to observe hide/show
- Anything the user expects to keep running while the tab is hidden

Note: `popstate` cannot fire on a hidden tab (it's user-driven), so pausing on hide is fine — see the toolbar example below.

## Calling `dispose()` to stop early

`cache.disposables.dispose('key')` tears down one specific resource without unmounting the logic. Use it when a state transition should end the resource — pause/resume a poller, stop a hover-only ticker on mouseleave, close a modal-scoped listener.

## Calling into the manager after unmount

`add()` and `dispose()` are no-ops once the logic has unmounted, so call them plainly.
Don't write `cache.disposables?.dispose(...)` or `if (!cache.disposables) return`; the manager is never null after mount.

An async continuation usually has to skip more than the disposable, though, because dispatching an action or reading `values` on a torn-down logic is its own bug.
Branch on `isDisposed` for that:

```ts
// The stream teardown aborts this request, so the catch can resume after the unmount
if (cache.disposables.isDisposed) {
  return
}
actions.connectionErrored(reason)
```

This matters most in a `finally`.
A request the unmount aborted rejects, and the `finally` then runs against a logic that no longer exists.

One caveat on a logic that mounts again.
The next mount puts a fresh manager on the cache, so a continuation left over from the previous life can reach `cache.disposables` and find a live one.
`isDisposed` reads `false` there, and disposing a shared key tears down the new life's resource.
Capture what the continuation needs while the logic is alive when that matters.

Do not guard a timer callback with `isDisposed` alone if it reads `values`.
The flag only moves on unmount, and replacing the kea context (which storybook does on every story mount) drops the logic from the store without unmounting it, so `isDisposed` stays `false` on a logic that no longer has a store.
The plugin does run every cleanup when the old context closes, so the resource itself goes away.
A callback that already fired, or one whose cleanup cannot stop it (an in-flight request resolving), still needs its own guard: compare `getContext()` against the context the resource was set up in — see `frontend/src/scenes/notebooks/Notebook/notebookKernelInfoLogic.ts`.

## Examples in the codebase

**Unnamed `setInterval` poller** — see the canonical example in [The pattern](#the-pattern) (`frontend/src/layout/navigation/noEventsBannerLogic.ts:14-21`).

**Keyed intervals with `dispose()` on hover-end / pause** — `frontend/src/lib/components/LiveUserCount/liveUserCountLogic.ts:94-118`

```ts
setIsHovering: ({ isHovering }) => {
    if (isHovering) {
        actions.setNow(new Date())
        cache.disposables.add(() => {
            const intervalId = setInterval(() => actions.setNow(new Date()), 500)
            return () => clearInterval(intervalId)
        }, 'nowInterval')
    } else {
        cache.disposables.dispose('nowInterval')
    }
},
pauseStream: () => {
    cache.disposables.dispose('statsInterval')
},
resumeStream: () => {
    actions.pollStats()
    cache.disposables.add(() => {
        const intervalId = setInterval(() => actions.pollStats(), props.pollIntervalMs ?? 30000)
        return () => clearInterval(intervalId)
    }, 'statsInterval')
},
```

**`setTimeout` with key for spam-replacement** — `frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts:1837-1846`

```ts
showSeekIndicator: () => {
    // Same key auto-disposes the previous timer when spamming
    cache.disposables.add(() => {
        const timerId = setTimeout(() => actions.hideSeekIndicator(), 600)
        return () => clearTimeout(timerId)
    }, 'seekIndicatorTimer')
},
```

**Multiple keyed window listeners in one `afterMount`** — `frontend/src/toolbar/bar/toolbarLogic.ts:655-688`

```ts
cache.disposables.add(() => {
  const clickListener = (e: MouseEvent): void => {
    /* ... */
  }
  window.addEventListener('mousedown', clickListener)
  return () => window.removeEventListener('mousedown', clickListener)
}, 'clickListener')

// popstate only fires on user-initiated back/forward, so a hidden tab won't
// generate events — pausing on hide (the default) is fine here. Opt out
// only if you must observe popstates while the tab is in the background.
cache.disposables.add(() => {
  const popstateHandler = (): void => actions.maybeSendNavigationMessage()
  window.addEventListener('popstate', popstateHandler)
  return () => window.removeEventListener('popstate', popstateHandler)
}, 'popstateListener')
```

**`visibilitychange` listener with `pauseOnPageHidden: false`** — `frontend/src/scenes/product-tours/productTourLogic.ts:647-663`

```ts
openToolbarModal: () => {
    cache.disposables.add(
        () => {
            const handler = (): void => {
                if (document.visibilityState === 'hidden') {
                    actions.handleToolbarTabVisibility()
                }
            }
            document.addEventListener('visibilitychange', handler)
            return () => document.removeEventListener('visibilitychange', handler)
        },
        'toolbarModalVisibility',
        { pauseOnPageHidden: false }
    )
},
closeToolbarModal: () => {
    cache.disposables.dispose('toolbarModalVisibility')
},
```

**`MediaQueryList` listener in `events(afterMount)`** — `frontend/src/layout/navigation-3000/themeLogic.ts:108-118`

```ts
events(({ cache, actions }) => ({
    afterMount() {
        cache.disposables.add(() => {
            const prefersColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)')
            const onPrefersColorSchemeChange = (e: MediaQueryListEvent): void =>
                actions.syncDarkModePreference(e.matches)
            prefersColorSchemeMedia.addEventListener('change', onPrefersColorSchemeChange)
            return () => prefersColorSchemeMedia.removeEventListener('change', onPrefersColorSchemeChange)
        }, 'prefersColorSchemeListener')
    },
})),
```

## Anti-patterns to convert

Bare `cache.<thing>` + `beforeUnmount` cleanup is the pattern this plugin replaces. Convert these on sight.

**Before** (`frontend/src/lib/components/HedgehogMode/hedgehogModeLogic.ts:205-215`):

```ts
afterMount(({ actions, cache }) => {
    cache.syncInterval = setInterval(() => actions.syncFromState(), 1000)
}),
beforeUnmount(({ cache }) => {
    if (cache.syncInterval) {
        clearInterval(cache.syncInterval)
        cache.syncInterval = null
    }
}),
```

**After** — note the `beforeUnmount` block is gone entirely; the cleanup function returned from `setup` is what the plugin runs on unmount:

```ts
afterMount(({ actions, cache }) => {
    cache.disposables.add(() => {
        const id = setInterval(() => actions.syncFromState(), 1000)
        return () => clearInterval(id)
    }, 'syncInterval')
}),
```

Other open conversion targets:

- `frontend/src/scenes/welcome/welcomeDialogLogic.ts:325-345` — bare `window.addEventListener('storage', ...)` with `cache.storageHandler` stashed manually
- `products/signals/frontend/inbox/inboxSceneLogic.ts:260-267` — bare `setInterval` cleared by hand on every state change

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.