Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

4.0 KiB

Keyed logics — many instances of one logic

Most logics are singletons — one global instance, accessed everywhere. But when you need one instance per resource (per insight, per dashboard, per chat thread), you key the logic by the resource id.

Why a keyed logic and not a singleton

A singleton holding currentFooId and currentFoo works for one foo at a time. Open two foos side-by-side (split view, tab system) and they fight over the same state.

A keyed logic creates an independent instance per key — same code, separate state, separate listeners, separate cache. The redux store puts each instance under its own path.

The minimum keyed shape

import { actions, kea, key, path, props, reducers } from 'kea'

import type { fooLogicType } from './fooLogicType'

export interface FooLogicProps {
  fooId: string
}

export const fooLogic = kea<fooLogicType>([
  props({} as FooLogicProps), // 1
  key((props) => props.fooId), // 2
  path((key) => ['scenes', 'foo', 'fooLogic', key]), // 3
  actions({
    /* ... */
  }),
  reducers({
    /* ... */
  }),
])
  1. props({} as FooLogicProps) declares the typed default. Always cast — kea can't otherwise type-check the props at the key function.
  2. key picks one field (or a composite) out of props as the instance identifier. Same key → same instance.
  3. path includes the key. Without this, every instance writes to the same redux node and they collide. The function form path((key) => [...]) is what wires the key in.

Calling a keyed logic

// In another logic
connect((props) => ({ values: [fooLogic(props), ['foo']] }))

// In a component
const logic = fooLogic({ fooId })
const { foo } = useValues(logic)
const { setName } = useActions(logic)

fooLogic(props) returns the wrapper for that specific instance — same return type as fooLogic.build(props) but lazily mounted on use.

Composite keys

When a single id isn't enough, build a composite key with a helper that lives next to the logic:

// keyForInsightLogicProps.ts
export function keyForInsightLogicProps(defaultKey: string) {
  return (props: InsightLogicProps): string => {
    return props.dashboardItemId ? `${props.dashboardItemId}::${props.dashboard ?? 'no-dashboard'}` : defaultKey
  }
}

// fooLogic.ts
key((props) => keyForInsightLogicProps('new')(props))

The helper makes the key predictable and reusable across connect calls — anyone who needs to talk to the same instance can compute the same key.

Required vs optional keys

// Throws if fooId is missing — for logics that must have a resource
key((props) => {
    if (!props.fooId) throw new Error('Must init fooLogic with fooId')
    return props.fooId
}),

// Falls back for "new" / "draft" cases
key((props) => props.fooId ?? 'new'),

Throwing is friendlier than silently mounting under a key like undefined, which will mysteriously share state across all the places that forgot the prop.

Reacting to a key or prop change

Two distinct cases — distinguish them:

  • Props change, key stays the same. Same logic instance, new props. Use propsChanged to react.
  • Key changes. A different instance is mounted; the old one is torn down when its React subscriber unmounts. propsChanged does not fire across instances — it's a same-instance hook. The new instance gets afterMount as usual.
// Same instance, new props (a non-keyed prop changes):
propsChanged(({ actions, props }, oldProps) => {
    if (props.filter !== oldProps.filter) {
        actions.loadFoo()
    }
}),

// New instance via key change: afterMount fires on the new one as usual
afterMount(({ actions }) => {
    actions.loadFoo()
}),

Typing the export

export const fooLogic: LogicWrapper<fooLogicType> = kea<fooLogicType>([...])

The explicit LogicWrapper<fooLogicType> annotation gives you the right overloads for calling fooLogic(props). For singletons it's not needed; for keyed logics it makes the call-site types behave.

Anti-patterns

See anti-patterns.md for the consolidated catalogue.