.agents/skills/frontend-large-feature-architecture/references/local-feature-state.md
Large frontend features should not let one component or hook own everything. That shape makes one checkbox, hover, or row-selection change rerun the code that also builds filters, columns, data wrappers, expensive cells, drawers, actions, and routing glue.
The goal is to make each state change wake only the UI and actions that semantically depend on it.
Start from the ownership baseline in big-feature-rules.md, then add a local
vanilla Zustand store only when state is high-frequency, shared across multiple
subtrees in the mounted feature, or must survive row/item remounts.
Use this shape:
useState.actions/*.ts or store actions.README.md owner map.For a concrete owner-map template, read feature-readmes.md. Do not use the
README as a changelog; record durable ownership facts and the next migration
slice.
Langfuse pages are often local-state heavy: filters, saved views, selected rows, expanded rows, drawers, peek navigation, lazy row load state, and view-local actions. Those states usually belong to one mounted page instance, not the whole application.
Use local feature stores for this state. Create the store in the page/view and destroy it on unmount. This keeps multiple mounted instances independent, avoids cross-route state leaks, and makes ownership visible.
Global state is reserved for product state that is genuinely shared across features or routes. Do not promote state globally to avoid prop drilling or to make a large component smaller.
Do not add a store just to make a PR look architectural. If the immediate problem is an inline export workflow, duplicated filter option shaping, or a large column builder, first extract an action or pure helper. Use the store when state needs selective subscriptions or must survive row remounts within one mounted feature instance.
Prefer lazy useState for local store instances:
const [store] = useState(() =>
createFeatureStore({
initialProjectId: projectId,
}),
);
This expresses the real lifecycle: one store instance for the committed mounted
view. The unused setter is acceptable. useMemo is the wrong default because it
is a render-time cache for derived values, not an ownership boundary for an
external store instance. A local store is stateful infrastructure, so treat its
identity as state.
useRef can also hold a stable instance, but prefer useState unless a ref is
needed for imperative setup. Keep store creation pure; sync changing route/query
inputs into named store actions such as resetForFeature(...) or init(...).
Complex workflows should be independent functions. Put surface-specific actions
in actions/*.ts, or make them named store actions when they are tightly coupled
to local feature state.
The component wires hooks, route params, stores, analytics, and query helpers. The action owns the workflow: refetching through callbacks, reading the passed store if needed, calling pure helpers, performing browser side effects, and emitting analytics.
Actions must not call React hooks. If a workflow needs a lot of context, pass the local store instance or a small dependency object rather than threading long prop chains through view components.
Example:
await exportFeatureData({
capture,
fetchDetails,
projectId,
refetchSummary,
selectedIds,
});
For substantial data shaping, export a pure helper next to the action so the transformation can be tested without rendering the page.
Use immutable plain objects for keyed state that must be selector-friendly:
type FeatureStoreState = {
selectedIds: Record<string, true>;
activeId: string | null;
actions: {
toggleSelected: (id: string, selected: boolean) => void;
setActiveId: (id: string | null) => void;
};
};
Avoid mutating Set or Map in place. If you use them, replace the whole
instance when updating.
value changes on row selection, hover, scroll, active row,
expanded row, or other high-frequency state.useMemo is used to own a local external store instance.src/components/* component calls a feature-scoped store hook. That
silently breaks other callers that do not mount the feature provider.memo helps, but it does not fix a bad state
boundary.useEffect derives ordinary UI state from fetched data.actions/*.ts.