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.tsx file
- Adding builders to an existing logic (actions, reducers, selectors, listeners, loaders, forms)
- Choosing between
reducer vs selector vs cache vs loader for 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](../using-kea-disposables/SKILL.md) —
setInterval, addEventListener,
and any other resource that needs cleanup.
If your work overlaps it, read the companion skill first.
Core principles
- Business logic lives in a logic, not in a component.
[CLAUDE.md](../../../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](references/reacting-to-changes.md).
- Generated types are the contract. Every logic has an inline generated
MakeLogicType block above its kea() call. Import a logic type from the logic source file, never from a separate *LogicType.ts file.
- Resources that need cleanup go through
cache.disposables. See the
[using-kea-disposables](../using-kea-disposables/SKILL.md) skill.
Anatomy at a glance
import { MakeLogicType, actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import * as api from 'products/foo/frontend/generated/api'
export interface FooLogicProps {
fooId: string
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface fooLogicValues {
name: string
nameUpper: string
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface fooLogicActions {
setName: (name: string) => { name: string }
}
export type fooLogicType = MakeLogicType<fooLogicValues, fooLogicActions, FooLogicProps>
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.foosRetrieve(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](references/loading-data.md).
- 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](../using-kea-disposables/SKILL.md).
cache.foo is an escape hatch for transient flags the UI never reads — reach for it last, not first. See [references/state-decision.md](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](references/state-decision.md) |
| Load data from the API |
[references/loading-data.md](references/loading-data.md) |
| Poll an endpoint or refresh on an interval |
[references/polling.md](references/polling.md) |
| Build a form |
[references/forms.md](references/forms.md) |
| Sync state with the URL |
[references/routing.md](references/routing.md) |
| Persist state across reloads |
[references/persisting-state.md](references/persisting-state.md) |
| React to a value change |
[references/reacting-to-changes.md](references/reacting-to-changes.md) |
| Have multiple instances of one logic |
[references/keyed-logics.md](references/keyed-logics.md) |
| Share state across logics or a component subtree |
[references/connecting-logics.md](references/connecting-logics.md) |
| Test the logic |
[references/testing.md](references/testing.md) |
| Recognise a pattern you should convert on sight |
[references/anti-patterns.md](references/anti-patterns.md) |
Types and typegen
import { MakeLogicType, kea } from 'kea'
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface fooLogicValues {
name: string
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface fooLogicActions {
setName: (name: string) => { name: string }
}
export type fooLogicType = MakeLogicType<fooLogicValues, fooLogicActions>
export const fooLogic = kea<fooLogicType>([...])
Inline type blocks are produced by kea-typegen 3.8.3. Commands:
pnpm --filter=@posthog/frontend typegen:watch — watch mode while writing logics
pnpm --filter=@posthog/frontend typegen:write — one-shot write
pnpm --filter=@posthog/frontend typegen:check — CI parity check
Iterating on one logic
Full typegen over the whole codebase is slow. When you're iterating on a single logic, scope typegen to that file:
# Regenerate the type for one logic
pnpm --filter=@posthog/frontend typegen:file frontend/src/scenes/foo/fooLogic.ts
Use this loop while writing the logic. The current tsgo does not support combining the project config with a file argument, so run the full typegen:check and typescript:check once at the end to confirm nothing else broke.
Do not create or import a separate *LogicType.ts file. Change the logic and re-run typegen so the inline generated block stays authoritative.
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.