Skip to main content

using-kea-disposables

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` (`frontend/src/kea-disposables.ts`). Replaces the bare `cache.foo = setInterval(...)` + `beforeUnmount: clearInterval(cache.foo)` pattern and auto-pauses background work when the tab is hidden.

Stars
34,779
Source
PostHog/posthog
Updated
2026-05-31
Slug
PostHog--posthog--using-kea-disposables
View on GitHubRaw SKILL.md

// install — copy + paste into any project

mkdir -p .claude/skills && curl -fsSL https://raw.githubusercontent.com/PostHog/posthog/HEAD/.agents/skills/using-kea-disposables/SKILL.md -o .claude/skills/using-kea-disposables.md

Drops the SKILL.md into .claude/skills/using-kea-disposables.md. Works with Claude Code, Cursor, and any agent that loads SKILL.md files from .claude/skills/.

Using kea disposables

Every kea logic in this repo has cache.disposables injected by the local disposablesPlugin (frontend/src/kea-disposables.ts, registered globally in frontend/src/initKea.ts). 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.

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

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):

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

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.

Examples in the codebase

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

Keyed intervals with dispose() on hover-end / pausefrontend/src/lib/components/LiveUserCount/liveUserCountLogic.ts:94-118

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-replacementfrontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts:1837-1846

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 afterMountfrontend/src/toolbar/bar/toolbarLogic.ts:655-688

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: falsefrontend/src/scenes/product-tours/productTourLogic.ts:647-663

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

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):

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:

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
  • frontend/src/scenes/inbox/inboxSceneLogic.ts:260-267 — bare setInterval cleared by hand on every state change