New to Claude Skills? Learn how to install them →

Uposthog on GitHub

Using Kea Disposables

Free

Simplify resource management in Kea logic.

by posthog37.6k stars on posthog/posthog
2 views
Updated Aug 11, 2026
Get this skill

Free · Opens the source repo

What Using Kea Disposables does

The Using Kea Disposables skill provides a streamlined approach to managing resources that require cleanup in Kea logic. It introduces the cache.disposables mechanism, which allows developers to easily register timers, event listeners, and other resources that need explicit teardown. By utilizing this skill, you can avoid the boilerplate of manually clearing intervals or removing event listeners in the beforeUnmount lifecycle method. Instead, the skill automatically handles cleanup when the logic unmounts or when the tab visibility changes, improving both code readability and performance.

This skill is particularly useful when adding resources like setInterval, setTimeout, or event listeners such as window.addEventListener. By using cache.disposables.add(setup, key?, options?), you can register these resources in a way that ensures they are properly disposed of without cluttering your logic with repetitive cleanup code. The skill also supports the option to pause background tasks when the tab is hidden, which can significantly reduce CPU and network usage.

Developers working with Kea will find this skill beneficial for maintaining cleaner code and avoiding common pitfalls associated with resource management. It encourages best practices by centralizing the management of lifecycle events and resource cleanup, making it easier to write and maintain complex logic. The skill is designed for those who want to enhance their Kea applications with more efficient resource handling, ensuring that background work is paused appropriately and resources are cleaned up as needed.

In summary, Using Kea Disposables is an essential skill for any developer working with Kea who wants to simplify resource management and improve the overall quality of their codebase.

When to use it

Use this skill when you need to manage timers, event listeners, or other resources that require cleanup in your Kea logic.

When not to use it

Avoid this skill if you do not use Kea or if your resource management needs are simple and do not require explicit teardown.

What you can build with it

Adding Timers

Use cache.disposables to manage polling intervals or timeouts without cluttering your logic.

Event Listener Management

Register event listeners with automatic cleanup to prevent memory leaks when components unmount.

Dynamic Resource Control

Easily dispose of resources based on state changes to optimize performance and resource usage.

How to install Using Kea Disposables

View source

1. Install with the skills CLI

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

2. Or install it manually

Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.

Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs

Inside SKILL.md

Written by posthog

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

Frequently asked questions about Using Kea Disposables

Similar skills