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.
12 KiB
Anti-patterns — convert on sight
This is the list of shapes that look reasonable but cause specific real problems. When you see one in code you're reviewing or working in, convert it.
State containers
Bare setInterval / setTimeout with beforeUnmount cleanup
// don't
afterMount(({ actions, cache }) => {
cache.timer = setInterval(() => actions.poll(), 5000)
}),
beforeUnmount(({ cache }) => clearInterval(cache.timer)),
Use cache.disposables instead — handles cleanup and auto-pauses on hidden tabs.
See using-kea-disposables.
Reducer mirroring a loader's value
// don't
loaders({ foo: [null, { loadFoo: async () => api.get() }] }),
reducers({ foo: [null, { loadFooSuccess: (_, { foo }) => foo }] }), // duplicate
The loader already gives you foo, fooLoading, and the success/failure actions.
Delete the reducer.
isLoadingFoo reducer alongside a loader
// don't
reducers({
isLoadingFoo: [false, { loadFoo: () => true, loadFooSuccess: () => false }],
})
fooLoading exists already.
Reducer that's a deterministic function of other state
// don't
reducers({
fullName: [
'',
{
setFirstName: (state, { first }) => `${first} ${state.split(' ')[1] ?? ''}`,
setLastName: (state, { last }) => `${state.split(' ')[0] ?? ''} ${last}`,
},
],
})
Selector. See state-decision.md.
cache.foo used to render UI
// don't
listeners(({ cache, actions }) => ({
open: () => {
cache.isOpen = true
}, // no re-render!
}))
// then in component: cache.isOpen — won't update
cache has no subscription. Use a reducer.
try/catch around the whole loader handler
// don't
loadFoo: async () => {
try {
return await api.foos.get(props.fooId)
} catch (e) {
return null
}
},
kea-loaders already catches and emits loadFooFailure with the error. Listen for
that action if you need custom handling, and only re-throw inside the handler if you
genuinely need to abort the loader machinery.
Non-async logic in a loader
If the handler doesn't await anything, it's a reducer or selector wearing a loader's
hat. Loaders are for async — the action/success/failure trio and the xLoading boolean
are noise when the work is synchronous.
Loading data and persistence
localStorage.setItem directly inside a listener
Two tabs will race; serialisation is your problem; you've reinvented { persist: true }.
Use the kea-localstorage plugin — second slot of a reducer tuple.
Persisted loader results
// don't
foo: [
null as Foo | null,
{ persist: true }, // serves stale data on next mount
{ loadFooSuccess: (_, { foo }) => foo },
],
Loaders re-fetch on mount anyway, so persisting the value just shows stale data during the gap. Persist only if the previous value is genuinely better than a loading spinner.
Persisting a selector
Selectors aren't reducers. Persist the inputs and recompute.
window.matchMedia(...) registered without going through cache.disposables
Same shape as bare setInterval — you'll leak a listener on unmount. Wrap the
addEventListener / removeEventListener pair in cache.disposables.add(...). See
using-kea-disposables.
Reactions
subscriptions reacting to a value set by an action
// don't
subscriptions(({ actions }) => ({
foo: (foo) => {
if (foo) actions.processFoo()
},
}))
Listen to the action that sets foo instead. See
reacting-to-changes.md.
subscriptions in a products/* logic
import { subscriptions } from 'kea-subscriptions' // build fails
kea-subscriptions is only in frontend/. Use listeners.
useEffect in a component reacting to a logic value
// don't
const { foo } = useValues(fooLogic)
useEffect(() => {
if (foo) doSideEffectFoo()
}, [foo])
That's a listener. Move it into the logic.
propsChanged body that doesn't guard
// don't
propsChanged(({ actions, props }) => {
actions.loadFoo() // fires on every parent re-render
})
Guard with props.fooId !== oldProps.fooId. React will re-render with referentially
different but structurally equal props.
A listener that just dispatches one other action
// smells off
listeners(({ actions }) => ({
setX: ({ x }) => actions.setY(x),
}))
Sometimes this is the right shape — adapting payload shape across a logic boundary, or
re-firing a connected action you don't own. But if you do own both actions and they
take the same args, this is a hint that the action shape is wrong. Check whether you
can rename the upstream action, or whether two callers should share a body via
sharedListeners.
Polling without a stop condition
If the thing you're waiting on can finish (a job, a migration, a build), listen for
the terminal state and cache.disposables.dispose('pollKey'). Otherwise the poller
hammers the API indefinitely while the page is open.
Polling on a singleton when only a subset of views need fresh data
Mount the polling logic from the view that needs it (via useMountedLogic or
useValues) so the timer is scoped to where it matters.
setInterval(... 0) as "next tick"
If you need to act on the next event-loop turn, use a listener on the action that
should trigger the work, or requestAnimationFrame for frame-boundary cases.
setInterval(0) is a wasted timer.
Keying
Keyed logic with no key in the path
// don't
key((props) => props.fooId),
path(['scenes', 'foo', 'fooLogic']), // key not visible in path
Instance identity is handled by key, but Insights convention is to also append the
key to path so the key shows up in redux devtools, in typegen output, and in any
log lines that include the logic path. Use path((key) => ['...', key]).
key reading from window, locals, or another logic
// don't
key(() => window.currentFooId),
Keys must be derivable from props alone. Anything else and two callers with the same props get different instances — or worse, the same instance when they shouldn't.
Different key shapes across callers
// don't
key((props) => props.fooId)
// elsewhere: `${props.fooId}-${props.mode}` for the same logic
Pull the key derivation into a helper. Inconsistent keys mean callers don't talk to the same instance.
Mounting a keyed logic with fooLogic() (no props)
Same as fooLogic({}) — keys to undefined. All such callers share one phantom
instance. Always pass props at the call site.
Forgetting to type the export when the logic is keyed
// don't (call-site types end up as any)
export const fooLogic = kea<fooLogicType>([
props({} as FooLogicProps),
key((props) => props.fooId),
// ...
])
// do
export const fooLogic: LogicWrapper<fooLogicType> = kea<fooLogicType>([...])
Routing
Hard-coded URLs
// don't
urlToAction(({ actions }) => ({ '/foos/:id': ({ id }) => actions.openFoo(id) }))
Use urls.foo(id) so renames in one place propagate everywhere.
actionToUrl / urlToAction feedback loop
The classic: setQuery updates URL → URL change fires setQuery again → infinite
loop. See routing.md for guard patterns.
Mutating window.location directly
// don't
window.location.href = '/foos/' + id
Bypasses kea-router entirely — urlToAction won't fire. Use router.actions.push.
useNavigate / useParams in a component when there's a logic
URL is logic state. Move the reaction to urlToAction and the navigation to
actionToUrl (or router.actions.push inside a listener) so the logic owns the
behaviour and the component shrinks back to view code.
Forms
Validation in the submit handler
// don't
submit: async (values) => {
if (!values.email) throw new Error('Email required')
await api.send(values)
}
Validation belongs in errors. Submit is for the work.
Returning '' from errors
errors: ({ email }) => ({ email: email ? '' : 'Required' }) // '' is not undefined — counts as a present error
kea-forms treats any non-undefined value in the errors map as a present error,
so '' and 'Required' both make the field invalid. Return undefined for
"no error".
Side effects inside errors
errors re-runs on every keystroke. Keep it pure — no logging, no analytics, no
async work. Reach for a listener on setFooValues if you need to react to typing.
useState for form values
// don't
const [email, setEmail] = useState('')
const [name, setName] = useState('')
That's a forms builder waiting to be written.
Manual setFoo / setFooValue chains instead of <Field>
// don't
<Input value={values.signup.email} onChange={(v) => actions.setSignupValue('email', v)} />
<Field name="email"> already wires the change handler. Use it; the component shrinks.
Types
Creating or importing *LogicType.ts
Logic types are generated inline in the logic source. A separate type file will drift or be deleted by typegen. Import the type from the logic source and re-run typegen.
Importing the runtime logic when you only need its type
// don't
import { fooLogic } from './fooLogic'
type FooState = ReturnType<typeof fooLogic.build>['values']
// do
import type { fooLogicType } from './fooLogic'
type FooState = fooLogicType['values']
Importing the runtime forces it to load even if you never call it.
Mounting and unmounting
.mount() with no stored unmount
// don't
otherLogic.build(props).mount() // leaks
Always store the return value and dispose it, ideally via cache.disposables.
Calling initKea outside initKea.ts / initKeaTests
// don't
import { initKea } from 'initKea'
initKea() // resets the entire store
Blows away every mounted logic in the app.
React integration
BindLogic for a singleton
Singletons have one instance. BindLogic is for keyed logics. For singletons, just
call useValues(fooLogic).
Reading another logic's state from a render function
// don't
function FooView(): JSX.Element {
const x = otherLogic.values.x // no subscription — won't re-render when x changes
return <div>{x}</div>
}
Use useValues(otherLogic) so the component subscribes and re-renders on change.
useEffect to mount another logic and dispatch an action
// don't
useEffect(() => {
otherLogic(props).actions.x()
}, [])
Use useMountedLogic(otherLogic) to manage the lifecycle, plus useActions to call
the action — or do the mounting inside a logic that owns the relationship.
connect.values: [otherLogic, ['everything']] copy-paste
Pull only the names you actually use. The generated types are tighter, downstream refactors stay obvious, and unused names won't trigger spurious re-renders.
Business logic in useEffect
If the body of a useEffect does anything beyond mounting / focus / DOM, it
probably belongs in a logic listener.
History
The patterns banned here aren't theoretical — most have a PR behind them where the thing went wrong in real code:
- PR #38754 — introduced the
disposables plugin to replace bare
setInterval+beforeUnmountcleanup. - PR #40284 — added auto-pause on
hidden tab to disposables. This is why polling through
cache.disposablesdoesn't waste cycles when the user switches away. - PR #58691 — fixed a subtle bug where disposables registered while the page was hidden would still start immediately, defeating the auto-pause.
- PR #48042 — canonical example of
converting a "bare
setIntervalpoll" tocache.disposables(the managed-migrations logic). Read this alongside the disposables skill if you're doing the same conversion in another product. - Commit
7ef47b68e75— whykea-subscriptionsdoesn't work inside aproducts/*logic, and the listener-based fix.