All skills
posthog avatar

/using-kea-disposables

@a42a477 official
by posthogposthog/posthog40k stars
3,379

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.

Use this Skill: https://skilld.dev/gh/posthog/posthog/using-kea-disposables

This session only. Nothing lands on disk.

SKILL.md

β‰ˆ149 tokens always: the name and description. β‰ˆ2.8k when used: this file.

Using kea disposables

Every kea logic in this repo has cache.disposables injected by disposablesPlugin from the 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

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)
    })
}),

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

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:

// 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 (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

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

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

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

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
  • products/signals/frontend/inbox/inboxSceneLogic.ts:260-267 β€” bare setInterval cleared by hand on every state change

Source: SKILL.md on GitHub

No alerts19d3 checks Β· Risk SAFE
  • Gen Agent Trust Hub19d

    This skill provides best-practice guidelines for managing resources like timers and event listeners within a specific frontend framework. It focuses on ensuring proper cleanup to prevent memory leaks and reducing CPU usage in background tabs, with no security issues detected.

  • Socket19d

    No alerts

  • Snyk19d

    Risk: LOW Β· No issues

Signed by skilld at a42a477. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 3 weeks ago
  • Frontend
  • kea
  • javascript
  • timers
  • event-listeners
  • resource-cleanup
  • disposables
  • visibility-pausing

README badge

README badge for posthog/posthog/using-kea-disposables

Manages cleanup for timers, event listeners, and other resources in kea logic by providing `cache.disposables.add()` and `cache.disposables.dispose()` methods. Replaces the pattern of manually stashing handles in cache and clearing them in `beforeUnmount`, and automatically pauses background work when the page tab is hidden.

Generated from the current SKILL.md.

When should I use disposables instead of beforeUnmount?
Use disposables for any resource that needs explicit cleanup β€” timers, event listeners, subscriptions. Reserve beforeUnmount only for teardown that isn't a resource you control, like flushing state or calling third-party dispose() methods.
What happens when I register a disposable with a key that already exists?
The plugin automatically disposes the previous resource first, then runs the new setup. This is useful for spam-replacement patterns where repeated calls should replace the prior timer or listener.
Does pauseOnPageHidden affect all disposables by default?
Yes. By default (pauseOnPageHidden: true), cleanup runs when the tab is hidden and setup re-runs on focus. Opt out only for listeners that must fire while hidden, like storage, online/offline, or visibilitychange itself.
How do I stop a disposable early without unmounting the logic?
Call cache.disposables.dispose('key') with the key you registered the resource under. This is useful when a state transition should end the resource, like pausing a poller or stopping a hover-only ticker.

Generated from the current SKILL.md. These answers refresh after source changes.