writing-kea-logics

作者: posthog

Guide for writing or reviewing PostHog kea logic files (`*Logic.ts` / `*Logic.tsx`). Use when creating a new logic, adding…

npx skills add https://github.com/posthog/posthog-foss --skill writing-kea-logics

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)

If your work overlaps it, read the companion skill first.

Core principles

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Resources that need cleanup go through cache.disposables. See the using-kea-disposables skill.

Anatomy at a glance

import { MakeLogicType, actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'

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.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: propskeypathconnectactionsformsloadersreducersselectorssharedListenerslistenerssubscriptions (rare) → windowValuesurlToAction / actionToUrlafterMount / 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:

  1. Does it come from an HTTP call? Use a loader.
  2. Can it be computed from other state? Use a selector.
  3. Does an action change it, and does the UI need to re-render when it changes? Use a reducer.
  4. 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 / loaderreferences/state-decision.md
Load data from the APIreferences/loading-data.md
Poll an endpoint or refresh on an intervalreferences/polling.md
Build a formreferences/forms.md
Sync state with the URLreferences/routing.md
Persist state across reloadsreferences/persisting-state.md
React to a value changereferences/reacting-to-changes.md
Have multiple instances of one logicreferences/keyed-logics.md
Share state across logics or a component subtreereferences/connecting-logics.md
Test the logicreferences/testing.md
Recognise a pattern you should convert on sightreferences/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 — 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 typegen:file frontend/src/scenes/foo/fooLogic.ts

# 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 tsgo --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.

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.