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.
3.8 KiB
Reducer vs selector vs cache vs loader
Most kea bugs come from picking the wrong container for a piece of state. Use this decision flow before you write a new field.
Decision flow
- 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.
If none of the above fit and you need a transient, non-reactive flag the UI never
reads (a re-entry guard inside a listener, for example), then cache.foo is the
escape hatch. Reach for it last, not first — most "I need a flag" cases are actually
served by fooLoading from a loader or isFooSubmitting from a form. cache.foo
also has nothing to do with cache.disposables despite sharing the namespace — they
are separate concerns.
Why this matters
- Reducers are the only thing React subscribes to via
useValues. Putting reactive state incachemeans components won't re-render when it changes. - Selectors are memoised. A reducer that mirrors a computed value will drift out of sync the moment one of its inputs changes from somewhere else.
- Loaders give you
xLoadingfor free. Building your ownisLoadingFooreducer duplicates state you already have. cacheis escape-hatch state: re-entry guards, one-shot flags, debounce timers (when not using disposables). The UI must never depend on it.
Patterns
Action-driven state — reducer
reducers({
name: ['' as string, { setName: (_, { name }) => name }],
selectedIds: [
[] as string[],
{
select: (state, { id }) => [...state, id],
deselect: (state, { id }) => state.filter((x) => x !== id),
clearSelection: () => [],
},
],
})
A reducer listens to actions and returns the next state. Pure function, no side effects. The default value is the first array element; handlers are the last.
Derived state — selector
selectors({
selectedCount: [(s) => [s.selectedIds], (selectedIds): number => selectedIds.length],
isAllSelected: [
(s) => [s.selectedIds, s.allIds],
(selectedIds, allIds): boolean => selectedIds.length === allIds.length,
],
})
Selectors take an array of input selectors and a result function. Result is memoised on identity of the inputs. Always annotate the return type — typegen needs it for downstream consumers.
You can read another logic's selector inside a result function via s.someValue (if
connected) or otherLogic.selectors.someValue (when you'd rather not connect). The
connected form is preferred — type safety, explicit dependency declaration, and
nothing surprising at mount time.
Transient flag — cache
listeners(({ values, actions, cache }) => ({
submit: async () => {
if (cache.submitting) return // re-entry guard
cache.submitting = true
try {
await api.things.create(values.thing)
} finally {
cache.submitting = false
}
},
}))
cache is plain mutable state on the logic instance. No reducers, no actions, no
React subscription. Only use for things the UI doesn't render.
Common mistakes
- Reducer that duplicates a selector. If
fullNameis alwaysfirstName + ' ' + lastName, it's a selector, not a reducer. - Selector that should be a reducer. If the value is set by an action and not computed from anything else, it's a reducer.
- Cache used for UI state. No re-render will happen. The UI will look stale until some other action triggers a re-render coincidentally.
- Mirroring loader data into a reducer. Loaders already give you the value plus
xLoading. Don't add afooreducer alongside aloadFooloader.