using-kea-disposables

作成者: posthog

Use when adding timers (`setInterval`, `setTimeout`), event listeners (`window.addEventListener`, `document.addEventListener`,…

npx skills add https://github.com/posthog/posthog-foss --skill using-kea-disposables

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.

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.

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

posthogのその他のスキル

managing-experiment-lifecycle
posthog
実験の状態遷移をガイドします:起動、一時停止、再開、終了、バリアントの出荷、アーカイブ、リセット、複製。前提条件などをカバーします。
official
configuring-experiment-analytics
posthog
Configures the analytics side of a PostHog experiment — exposure criteria (default `$feature_flag_called` vs custom exposure events), primary and secondary…
official
error-tracking-hono
posthog
PostHogのHono向けエラートラッキング
official
error-tracking-react
posthog
PostHogのReact向けエラートラッキング
official
integration-android
posthog
Androidアプリケーション向けPostHogインテグレーション
official
integration-ruby
posthog
PostHog統合:Ruby SDKを使用するRubyアプリケーション向け
official
tuning-incremental-sync-config
posthog
同期の設定はExternalDataSchemaに保存され、external-data-schemas-partial-updateを使用していつでも変更できます。ほとんどの変更は非破壊的(次の同期で反映)ですが、一部(sync_typeの切り替え、プライマリキーの変更)は、同期データの破損を防ぐために慎重な対応が必要です。
official
instrument-integration
posthog
このスキルを使用して、アプリケーションにPostHog SDKを追加します。PostHogを初めてセットアップする場合や、PostHogの初期化が必要なPRをレビューする場合に使用してください。SDKのインストール、プロバイダーのセットアップ、基本設定をカバーします。任意のフレームワークや言語に対応しています。
official