Writing kea logics
PostHog uses kea as the state container for the frontend. Almost
all non-trivial business logic lives in a *Logic.ts / *Logic.tsx file, not in
React. We may be on a kea pre-release ahead of the version the keajs.org docs
cover — when in doubt, check pnpm-workspace.yaml for the pinned version.
This skill captures the PostHog-specific conventions on top of the upstream kea docs. When in doubt about a builder's signature, go upstream. When in doubt about whether to use it, read here.
Use this skill when
- Creating a new
*Logic.ts/*Logic.tsxfile - Adding builders to an existing logic (actions, reducers, selectors, listeners, loaders, forms)
- Choosing between
reducervsselectorvscachevsloaderfor a piece of state - Wiring a React component to a logic
- Reviewing a PR that introduces or modifies a kea logic
- Reviewing code that uses React hooks where a logic would be more idiomatic
Companion skills (do not duplicate)
- using-kea-disposables —
setInterval,addEventListener, and any other resource that needs cleanup. - making-scenes-tab-aware — scene root logics keyed by
tabId,tabAwareUrlToAction/tabAwareActionToUrl,useAttachedLogic.
If your work overlaps either of those, read the companion skill first.
Core principles
Business logic lives in a logic, not in a component. CLAUDE.md is explicit: "If there is a kea logic file, write all business logic there, avoid React hooks at all costs." Hooks are for view concerns.
One concept, one source of truth. Pick exactly one of: action-driven reducer, derived selector, async loader. Don't mirror the same value into multiple places.
Prefer listeners over
kea-subscriptions. Subscriptions install a redux subscription that re-runs on every dispatch and is measurably slower. Listen to the action that changed the value instead. See references/reacting-to-changes.md.Generated types are the contract. Every logic has an auto-generated
*LogicType.tsnext to it. Import withimport typeand pass it as the kea type parameter. Never edit the generated file.Resources that need cleanup go through
cache.disposables. See the using-kea-disposables skill.
Anatomy at a glance
import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import type { fooLogicType } from './fooLogicType'
export interface FooLogicProps {
fooId: string
}
export const fooLogic = kea<fooLogicType>([
props({} as FooLogicProps),
key((props) => props.fooId),
path((key) => ['scenes', 'foo', 'fooLogic', key]),
connect(() => ({ values: [teamLogic, ['currentTeamId']] })),
actions({ setName: (name: string) => ({ name }) }),
loaders(({ props }) => ({
foo: [null as Foo | null, { loadFoo: async () => api.foos.get(props.fooId) }],
})),
reducers({ name: ['', { setName: (_, { name }) => name }] }),
selectors({ nameUpper: [(s) => [s.name], (name): string => name.toUpperCase()] }),
listeners(({ actions }) => ({
loadFooSuccess: () => {
/* ... */
},
})),
afterMount(({ actions }) => {
actions.loadFoo()
}),
])
Conventional block order: props → key → path → connect → actions → forms → loaders →
reducers → selectors → sharedListeners → listeners → subscriptions (rare) →
windowValues → urlToAction / actionToUrl → afterMount / propsChanged / beforeUnmount.
You almost never need all of those — half a dozen blocks is typical. Pick the ones the logic actually uses and leave the rest out.
Decision flow — pick the right container before you start
Most kea bugs come from picking the wrong container for a piece of state. Work through this before reaching for any builder:
- Does it come from an HTTP call? Use a loader.
- Can it be computed from other state? Use a
selector. - Does an action change it, and does the UI need to re-render when it changes?
Use a
reducer. - Is it a timer, listener, or other disposable resource? Use
cache.disposables— see using-kea-disposables.
cache.foo is an escape hatch for transient flags the UI never reads — reach for it
last, not first. See references/state-decision.md
for the full breakdown.
Pattern index
Each reference covers one job-to-be-done with the pattern shape, why it's the right shape, and the trade-offs. File citations inside references are "examples in the wild today" — they age, so the pattern itself is the source of truth.
| You want to... | Read |
|---|---|
| Decide between reducer / selector / cache / loader | references/state-decision.md |
| Load data from the API | references/loading-data.md |
| Poll an endpoint or refresh on an interval | references/polling.md |
| Build a form | references/forms.md |
| Sync state with the URL | references/routing.md |
| Persist state across reloads | references/persisting-state.md |
| React to a value change | references/reacting-to-changes.md |
| Have multiple instances of one logic | references/keyed-logics.md |
| Share state across logics or a component subtree | references/connecting-logics.md |
| Test the logic | references/testing.md |
| Recognise a pattern you should convert on sight | references/anti-patterns.md |
Types and typegen
import type { fooLogicType } from './fooLogicType'
export const fooLogic = kea<fooLogicType>([...])
Generated *LogicType.ts files are produced by kea-typegen (we use the
3.6.2-leakfix.x private fork). Commands:
pnpm --filter=@posthog/frontend typegen:watch— watch mode while writing logicspnpm --filter=@posthog/frontend typegen:write— one-shot writepnpm --filter=@posthog/frontend typegen:check— CI parity check
Iterating on one logic — skip the full scan
Full typegen + tsc --noEmit over the whole codebase is slow. When you're iterating
on a single logic, scope both to that file:
# Regenerate the type for one logic
pnpm --filter=@posthog/frontend exec kea-typegen write \
-f frontend/src/scenes/foo/fooLogic.ts -r ./frontend/src
# Type-check just that file (and its imports — fast, but won't catch downstream
# breakage in other files that consume the new types)
pnpm --filter=@posthog/frontend exec tsc --noEmit \
frontend/src/scenes/foo/fooLogic.ts
Use this loop while writing the logic; run the full typegen:check /
typescript:check once at the end to confirm nothing else broke.
Never edit a *LogicType.ts file by hand — change the logic and re-run typegen.
For keyed logics, annotate the export explicitly:
export const fooLogic: LogicWrapper<fooLogicType> = kea<fooLogicType>([...]).
When in doubt
- Read the relevant reference above before inventing a new pattern.
- Read the upstream keajs.org docs for builder signatures.
- Search the repo for the builder name in
*Logic.ts— there are hundreds of working examples and the conventions are stable. - For state-management decisions, favour the option that lets you delete code elsewhere.