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...
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.
[](https://www.skillsdirectory.com/skills/posthog-using-kea-disposables-posthog)
---
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