name: zustand-store-pruning
description: Use when refactoring Keybase Zustand stores in shared/stores to remove screen-local or route-owned state, keep only truly global or cross-screen data in the store, prefer querying the Go service layer over frontend convenience caches, move one-off RPC calls into components with C.useRPC, and split the work into safe stacked commits.
Pruning Zustand Stores
Use this skill for store-by-store cleanup in the Keybase client. The goal is to shrink shared/stores/* down to state that is genuinely global, notification-driven, or shared across unrelated screens.
The Go service is the source of truth for a lot of this repo's state. Prefer querying the service from the owning feature over mirroring that data in Zustand. If a store exists mainly to warm a frontend cache and avoid a few extra local RPCs, that is usually a sign the store should go away.
Do not silently drop behavior. If a field or action is ambiguous, state the tradeoff and keep the behavior intact.
Many Keybase Zustand stores use Immer middleware. When moving store-owned objects out of Zustand into React
context, keep the same immutable state model by default: update provider state through Immer produce, and
insert Immer-produced copies of store objects such as messages, metadata, participants, Map, Set, arrays,
and nested objects instead of retaining mutable references from RPC parsing, caches, or compatibility stores.
Do not mutate existing React state containers in place and then return the same object. For helpers shared
between old Zustand code and new providers, make it clear whether the helper expects an Immer draft or a copied
immutable value, and ensure provider callers produce new identities for every changed container that selectors
or renders depend on.
Best Targets First
Start with small settings or wizard stores that mix form state and RPC orchestration:
shared/stores/settings-email.tsxshared/stores/settings-phone.tsxshared/stores/settings-password.tsxshared/stores/recover-password.tsxshared/stores/logout.tsx
Defer large or clearly global stores unless the user explicitly wants them:
shared/stores/config.tsxshared/stores/current-user.tsxshared/stores/router.tsxshared/stores/waiting.tsxshared/stores/convostate.tsxshared/stores/fs.tsxshared/stores/teams.tsx
See keybase-examples.md for store-specific guidance. Use store-checklist.md to track which stores are untouched, in progress, or done.
Triage Rules
Classify every store field and action before editing.
Keep it in the store if it is:
- Shared across multiple unrelated screens or tabs
- Updated by engine notifications, daemon pushes, or other background events
- Needed outside React components or used for cross-store coordination
- A long-lived cache keyed by usernames, team IDs, conversation IDs, paths, or other global entities
- Awkward or lossy to pass through navigation because it must survive independent screen entry points
Before keeping a cache just because several screens read it, ask whether reloading is good enough. For Keybase daemon-backed data, many RPCs are local and cheap, so a component-level reload on screen entry is often preferable to preserving store state.
Default assumption for this repo: RPCs usually hit a local service, so treat most reads as cheap unless you have evidence otherwise. Do not keep a Zustand cache just to avoid a small number of local RPCs.
Prefer reducing frontend bookkeeping. If a piece of state only mirrors what the service already knows, and the UI can query it on mount or refresh, delete the mirror instead of maintaining another store, notification path, and invalidation story in React.
Move it to component state if it is:
- Form input text, local validation errors, banners, or submit progress
- Temporary selection, highlight, filter, or sort state
- Wizard-step state that only matters while one screen or modal is mounted
- A one-shot RPC result only used by the current screen
- Reset on every screen entry and not meaningful elsewhere
Notification-fed UI does not automatically make state global. If a notification only updates a transient banner or screen-local status, keep the trigger where it already lands but move the rendered UI state into the owning screen unless multiple unrelated entry points truly need to read it.
Notification-fed data does not automatically justify a store-backed cache either. If mounted UI can subscribe with useEngineActionListener(...) and cheaply reload on mount, prefer that over keeping a small Zustand cache warm in the background.
Prefer the typed engine listener layer over store plumbing when:
- An engine action is only a screen-local refresh, prompt, or UI nudge
- The owning UI can safely miss the event while unmounted because it reloads on focus/mount
- No durable shared cache or badge state needs to be updated for other screens
Keep store-owned onEngineIncomingImpl handling when:
- The action updates shared caches, unread counts, badge counts, or other durable background state
- The effect must be retained while the screen is unmounted
- Multiple unrelated features need the same derived state
For this repo, the preferred shape is:
useEngineActionListener('keybase.1.homeUI.homeUIRefresh', () => {
reload(false, true)
})
and let shared/constants/init/shared.tsx remain the single engine entrypoint that fans out to both global stores and typed feature listeners.
When moving notification-fed state into mounted React hooks, do not reset local state with synchronous setState inside useEffect; this violates react-hooks/set-state-in-effect and causes cascading renders. Prefer deriving a blank/reset view from the current owner key during render, remounting an owner provider with key, or updating state only from the external subscription callback.
Move it to route params if it is:
- Data screen A already knows and screen B only needs for that navigation
- A modal confirmation payload such as IDs, usernames, booleans, or prefilled values
- Entry context that should be explicit in navigation rather than hidden in a global store
Move RPC calls out of the store if:
- The RPC is initiated by a screen and its result only updates that screen
- The RPC was only stored so the screen could call
dispatch.someAction() - Failure and waiting state are screen-local
Keep RPC logic in the store if:
- It services notification handlers or global refresh flows
- It fans results out to multiple screens
- It maintains a shared cache that survives navigation
Do not introduce module-level mutable state to preserve store behavior. This includes feature-local caches, in-flight dedupe singletons, listener registries, or other hidden module-scope coordination that recreates a store in disguise. If a refactor needs ephemeral bookkeeping that is private to one store, keep it inside the store closure or model it explicitly in store state. Module scope is acceptable for stable constants, pure helpers, and imports, not mutable coordination state.
When pruning a store, do not replace it with a module-local cache. Prefer one of these instead:
- Keep the data local to the owning screen or modal
- Move explicit entry context through route params
- Use a feature-local provider when multiple mounted descendants in one route need to share a loaded value
- Keep the real store if the data genuinely needs cross-route or background lifetime
Prefer reloading in components instead of keeping a store cache when:
- The data comes from the local service and reload latency is acceptable
- The cache only saves a small RPC but forces unrelated screens to coordinate through global state
- The notification path only exists to keep that convenience cache warm
- You do not have a concrete reason that the cache must survive navigation or serve multiple unrelated entry points
Prefer a shared feature hook instead of a store when:
- Several screens in one feature need the same cheap service-backed metadata
- The data does not need to survive while every consumer is unmounted
- Mounted consumers can listen for updates with
useEngineActionListener(...) - The alternative store would mainly exist as a convenience cache
Prefer direct store imports instead of shared/constants/init/shared.tsx callback plumbing when:
- A store's
dispatch.defer.*callback exists only to forward to another Zustand store - The target store is leaf-like and does not import the calling store back
- The callback does not need platform-specific override behavior
- The callback does not exist to break a real import cycle
Keep init-time callback plumbing when:
- The direct import would introduce a store cycle or make one more likely
- The callback is intentionally abstracting a platform split or runtime override
- The callback bundles several stores behind one bootstrap seam that still matters
For Keybase repo cleanup, this usually means:
- If
chat,teams,tracker,team-building,push, or similar stores are only calling into leaf stores likeusers,daemon, orsettings-contacts, preferuseLeafStore.getState()directly - Treat
shared/constants/init/shared.tsxas a smell when it is only wiring one store method straight into another - After replacing the direct call sites, delete the matching
deferfield types, default throwers, init wiring, dead imports, and any now-empty init helper
Concerns to check before making this change:
- Search both directions with
rgto confirm the target store does not already import the caller - Check
store-registry.tsx, dynamicrequire(...), and platform-split files before assuming a store is leaf-safe - Preserve bootstrap-only behavior that is still real; do not remove an init helper if it still wires unrelated callbacks
- Update tests and desktop/native stubs that may still reference the old
deferfield - Keep the remaining
defersurface coherent; if a store'sdeferobject becomes empty, remove the field rather than leaving dead scaffolding behind
For listener-driven multi-step flows, separate callback plumbing from UI state:
- If
incomingCallMaporcustomResponseIncomingCallMaponly need to keep live response handlers across navigation, move banners, form state, and selections out of the feature store first - Keep those live handlers in a dedicated transient handle module such as
shared/stores/flow-handles.tsx, not in a feature store field or a per-feature singleton map - Model the shared handle module after existing
dispatch.dynamic.*patterns: use anownerplusslotfor named handlers, and keyed one-shot registrations for cases like confirm screens that need an opaque token in route params - Prefer scoped registrations that return a disposer, and keep that disposer next to the registration site. Call it in
finally, effect cleanup, or other owner teardown paths instead of reconstructing cleanup later from owner/slot strings. - Clear named handlers when the flow step or RPC finishes. Do not rely on an
active/stale guard alone, because leaving closures in the registry retains response objects and can leak memory. If an older flow can finish after a newer one starts, the disposer must be token- or generation-scoped so the oldfinallydoes not clear the new handlers. - For keyed handlers, also keep a disposer and call it if the owning route or flow exits without consuming the token. Consuming the token should remain one-shot, and disposer cleanup after consume should be a no-op.
- Add thin feature-local wrappers next to the flow, for example
registerResetPromptorsubmitResetPrompt, so most call sites stay typed and readable - Register the module's
clearAllwith the shared reset plumbing soresetAllStores()clears these runtime handles too - Do not put screen data, waiting state, validation errors, or caches into the transient handle module. It is only for live callbacks or resolvers that must survive route changes
Refactor Workflow
1. Pick one store and map consumers
From shared/, find the store hook, its selectors, and its dispatch callers with rg.
Look for:
- Components reading store fields
- Components calling
dispatch.* - Notification handlers keeping the store in sync
- Navigation calls that could carry explicit params instead
- Engine actions that only poke one mounted feature through a store-owned
onEngineIncomingImpl
2. Build a keep-or-move table
For each field and action, label it:
keep-globalmove-componentmove-routedelete-derivedunsure
Do this before writing code. If several fields move together, migrate that whole screen flow in one pass.
Also label cross-store callback seams:
keep-init-plumbingreplace-direct-importreplace-engine-bridge
Use replace-direct-import when a dispatch.defer.* field only forwards to a leaf-like store and there is no import-cycle risk.
Use replace-engine-bridge when a store field or action exists only to bounce an engine event into one mounted feature.
3. Move screen-owned RPCs into components
Prefer C.useRPC when the RPC belongs to the current screen:
const loadThing = C.useRPC(T.RPCGen.someRpcPromise)
loadThing(
[rpcArgs, waitingKey],
result => {
setLocalState(result)
},
err => {
setError(err.message)
}
)
Keep waiting keys when they drive UI. If the store only existed to wrap that RPC, remove the wrapper action after consumers are updated.
4. Move per-screen flow state into components
Use React.useState, React.useEffect, and existing screen hooks. In plain .tsx files, use Kb.* components rather than raw DOM elements.
When the only remaining engine dependency is a mounted-screen reaction, subscribe in the component with the typed engine listener layer and keep navigation lifecycle in focus/blur hooks rather than init/shared.tsx.
If a helper hook, pure helper, or constant is only used by one component or one file, define it in that file instead of creating a sibling module. Split code out only when it is shared across files or the extracted boundary is meaningfully clearer than simple colocation.
If a store action or utility candidate is only used by one component or one file, move the code directly into that caller instead of creating a new util or leaving an imperative dispatch.* method on the store. Only extract a shared util when multiple files need the same behavior.
When pruning imperative dispatch.* helpers, check the caller count first. A single-caller helper should usually be inlined into that caller. Even with several callers in one feature, prefer colocated C.useRPC calls over introducing a wrapper hook unless the shared abstraction is pulling its weight. A helper with several unrelated callers can move to shared/util/* or a small file-local helper module if it still needs store access.
If a component reads multiple adjacent values from the same remaining store, prefer one selector with C.useShallow(...) over several subscriptions.
5. Move navigation-owned data into params
Use existing typed navigation patterns:
navigateAppend({name: 'someScreen', params: {foo, bar}})
Read params in the destination screen with the existing route helpers, for example:
const {params} = useRoute<RootRouteProps<'someScreen'>>()
Keep params limited to explicit entry context. Do not recreate a hidden global store inside the route object.
6. Collapse the store
After consumers move off the store:
- Delete dead fields, actions, helpers, imports, and tests
- Delete dead component-level leftovers created during the move, including unused params, temporary aliases, and underscore-prefixed placeholders that no longer serve a purpose
- Remove unused notification plumbing only if behavior is preserved
- Keep reset behavior coherent for whatever remains
- Preserve public store names unless there is a strong reason to rename them
After removing init callback plumbing:
- Delete the matching
dispatch.defertype entries - Delete the matching default throw implementations
- Delete
shared/constants/init/shared.tsxwiring for those callbacks - Delete now-empty init helpers and their startup calls
- Delete stale imports left behind in both the store and
shared.tsx
If nothing meaningful remains after moving screen-owned data out, delete the store entirely instead of leaving a one-field convenience cache behind.
Commit Shape
Prefer one stacked commit per store. Each commit should be reviewable on its own:
- Pick one store and one user-visible flow.
- Move local state and route-owned data to the component layer.
- Move screen-owned RPC calls to
C.useRPC. - Remove dead store fields and actions.
- Update or prune store tests that no longer apply.
Do not mix multiple unrelated stores into one commit unless they are tightly coupled and impossible to review separately.
Validation
On this machine, node_modules is not installed for this repo. Do pure code work only.
Still validate by inspection:
- Read all updated call sites
- Confirm no component still selects removed state or dispatches removed actions
- Confirm route param names line up between navigation and destination screens
- Confirm notification handlers still land somewhere intentional
If the refactor removes a store test, explain why the behavior moved to the component layer.