using-kea-disposables について
リポジトリ開発者向けに、Kea logic が作成するタイマー、リスナー、サブスクリプションなどを kea-disposables で管理するスキルです。自動クリーンアップ、タブ非表示時の処理停止、早期解放や別の破棄方法を説明します。
主な機能
- 01アンマウント時の自動クリーンアップ
- 02非表示タブでのバックグラウンド処理停止
- 03キー指定によるリソース置換
- 04キー指定による早期破棄
- 05非同期の破棄に関するガイド
活用シーン
- 01Kea logic へのタイマー追加
- 02イベントリスナーの管理
- 03サブスクリプションのクリーンアップ
- 04beforeUnmount のリソース処理のレビュー
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ posthog/ posthog --skill using-kea-disposablesインストール後、Claude Code で/using-kea-disposablesを入力して読み込まれたか確認します。
詳細
kea ディスポーザブルの使用
このリポジトリ内のすべての kea ロジックには、kea-disposables パッケージの disposablesPlugin によって cache.disposables が注入されています。これは frontend/src/initKea.ts と frontend/src/toolbar/index.tsx でグローバルに登録されています。明示的な破棄が必要なリソースを作成するときはいつでもこれを利用してください。このプラグインはアンマウント時にクリーンアップを実行し、タブが非表示になるとバックグラウンド処理を自動的に一時停止します。
このパッケージはこのリポジトリの外部でメンテナンスされているため、プラグイン自体への変更はここではなくそちらに属します。型もこのパッケージから提供されます: import type { DisposablesManager } from 'kea-disposables'。
クリーンアップのために beforeUnmount を追加しないでください。 このプラグインは、ロジックがアンマウントされるときに setup から返されたクリーンアップ関数を自動的に実行します(また、タブの表示状態の変化に応じて setup/cleanup を再実行します)。同じロジック内で先に登録した何かを clearInterval / clearTimeout / removeEventListener することだけを目的とした beforeUnmount を書いていることに気づいたら、そのリソースを代わりに cache.disposables.add(...) で登録し、beforeUnmount を削除してください。beforeUnmount は、自分で制御していないリソースの破棄(例: 状態のフラッシュ、localStorage への永続化、サードパーティの dispose() の呼び出し)のために取っておいてください。
このスキルを使うとき
afterMount、リスナー、またはサブスクリプションの中でsetIntervalやsetTimeoutを追加するときwindow.addEventListener、document.addEventListener、またはMediaQueryList.addEventListenerを追加するとき- 明示的な破棄が必要なサブスクリプション(WebSocket、EventSource、ResizeObserver、IntersectionObserver など)を追加するとき
- 素の
cache.<thing>とそれに対応するbeforeUnmountクリーンアップを持つロジックをレビューまたは編集するとき — 変換してください - 状態の変化によって、以前に登録したタイマーやリスナーを早めに破棄する必要があるとき
パターン
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
)
典型的な例(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)
})
}),
ロジックとちょうど同じ寿命を持つリソースの場合、disposables ビルダーがその afterMount を置き換えます:
import { disposables } from 'kea-disposables'
disposables(({ actions }) => ({
pollTimer: () => {
const id = window.setInterval(() => actions.loadCurrentTeam(), POLL_INTERVAL_MS)
return () => clearInterval(id)
},
})),
オブジェクトのキーは通常のディスポーザブルキーなので、dispose('pollTimer') で引き続き早めに停止できます。
pauseOnPageHidden を設定するには、素の関数ではなく { setup, options } を渡してください。
条件付きのものや、リスナーから再設定されるものは、引き続き cache.disposables.add(...) が必要です。
キーの選択
- キーなし — ファイア・アンド・フォーゲット。アンマウント時にのみクリーンアップされます。
afterMountで登録する一度きりのリスナーに適しています。 - 名前付きキー — 次の場合に必要です:
- 後で
cache.disposables.dispose(key)を呼び出して早めに停止する場合 - 同じセットアップが再追加される可能性があり、各呼び出しが前のものを置き換えるべき場合(連打時の置き換え)
- 後で
pauseOnPageHidden
デフォルト(true)はほぼすべての場合に正しい設定です — ポーリング、アニメーションのティッカー、ホバータイマーなど。バックグラウンドタブは処理を停止し、フォーカス時に再開するため、CPU とネットワークのコストが劇的に削減されます。
オプトアウト({ pauseOnPageHidden: false })は、ページが非表示の間もリスナーが発火し続ける必要がある場合にのみ行ってください:
- タブが非表示の間にも本当に発火しうるイベントのリスナー — 例:
storage(別のタブからの書き込み)、online/offline、message(Web Worker、Service Worker、または他のウィンドウから) visibilitychangeリスナー自体 — 非表示/表示を監視することがまさに目的です- タブが非表示の間も動き続けることをユーザーが期待するもの
注意: popstate は非表示のタブでは発火できません(ユーザー操作によるものだからです)。そのため非表示時の一時停止で問題ありません — 以下のツールバーの例を参照してください。
dispose() を呼び出して早めに停止する
cache.disposables.dispose('key') は、ロジックをアンマウントせずに特定の 1 つのリソースを破棄します。状態の遷移によってリソースを終了させるべき場合に使用します — ポーラーの一時停止/再開、mouseleave でホバー専用ティッカーを停止、モーダルスコープのリスナーを閉じる、など。
アンマウント後にマネージャーを呼び出す
ロジックがアンマウントされると add() と dispose() は何もしなくなるので、そのまま呼び出してください。
cache.disposables?.dispose(...) や if (!cache.disposables) return と書かないでください。マネージャーはマウント後に null になることはありません。
ただし、非同期の継続処理では、破棄されたロジックに対してアクションをディスパッチしたり values を読んだりすること自体がバグであるため、通常は disposables 以外のものもスキップする必要があります。
そのためには isDisposed で分岐します:
// The stream teardown aborts this request, so the catch can resume after the unmount
if (cache.disposables.isDisposed) {
return
}
actions.connectionErrored(reason)
これは特に finally で重要です。
アンマウントによって中断されたリクエストは reject し、その後の finally はもはや存在しないロジックに対して実行されます。
再度マウントされるロジックに関する注意点が 1 つあります。
次のマウントでは新しいマネージャーがキャッシュに設定されるため、前のライフサイクルから残った継続処理が cache.disposables に到達して、有効なマネージャーを見つける可能性があります。
そこでは isDisposed は false を返し、共有キーを破棄すると新しいライフサイクルのリソースを破壊してしまいます。
これが問題になる場合は、ロジックが生存しているうちに、継続処理が必要とするものをキャプチャしてください。
タイマーコールバックが values を読む場合は、isDisposed だけでガードしないでください。
このフラグはアンマウント時にしか変化せず、kea コンテキストの置き換え (storybook がストーリーのマウントごとに行います) はロジックをアンマウントせずにストアから削除するため、もはやストアを持たないロジックでも isDisposed は false のままです。
プラグインは古いコンテキストが閉じられるときにすべてのクリーンアップを実行するので、リソース自体は破棄されます。
すでに発火したコールバック、またはクリーンアップで停止できないコールバック (解決中のリクエストなど) には、独自のガードが依然として必要です。getContext() を、リソースがセットアップされたときのコンテキストと比較してください — frontend/src/scenes/notebooks/Notebook/notebookKernelInfoLogic.ts を参照してください。
コードベース内の例
名前のない setInterval ポーラー — The pattern の標準的な例を参照してください (frontend/src/layout/navigation/noEventsBannerLogic.ts:14-21)。
ホバー終了時 / 一時停止時に dispose() を呼ぶキー付きインターバル — 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 — 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')
},
1 つの 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')
pauseOnPageHidden: false を指定した visibilitychange リスナー — 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')
},
events(afterMount) 内の MediaQueryList リスナー — 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')
},
})),
変換すべきアンチパターン
裸の cache.<thing> と beforeUnmount によるクリーンアップは、このプラグインが置き換えるパターンです。見つけ次第変換してください。
変換前 (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
}
}),
変換後 — beforeUnmount ブロックは完全に削除されていることに注意してください。setup から返されるクリーンアップ関数が、アンマウント時にプラグインが実行するものです:
afterMount(({ actions, cache }) => {
cache.disposables.add(() => {
const id = setInterval(() => actions.syncFromState(), 1000)
return () => clearInterval(id)
}, 'syncInterval')
}),
その他の変換対象:
frontend/src/scenes/welcome/welcomeDialogLogic.ts:325-345— 裸のwindow.addEventListener('storage', ...)と、手動で退避されたcache.storageHandlerproducts/signals/frontend/inbox/inboxSceneLogic.ts:260-267— 状態が変わるたびに手動でクリアされる裸のsetInterval