frontend/.cursor/skills/migrate-state-management/SKILL.md
Do not introduce or recommend Redux or React Context. Migrate existing usage to the stack below.
Before changing code, classify what the state represents:
| If the state is… | Migrate to | Do not use |
|---|---|---|
| From API / server (versions, configs, fetched lists, time-series) | React Query | Redux, Context |
| Shareable via URL (filters, time range, page, selected ids) | nuqs | Redux, Context |
| Global/client UI (dashboard lock, query builder, feature flags, large client objects) | Zustand | Redux, Context |
| Local to one component (inputs, toggles, hover) | useState / useReducer | Zustand, Redux, Context |
If one slice mixes concerns (e.g. Redux has both API data and pagination), split: API → React Query, pagination → nuqs, rest → Zustand or local state.
When: State comes from or mirrors an API response (e.g. currentVersion, latestVersion, configs, lists).
Steps:
useQuery/API call) and where it is dispatched or set in Context/Redux.useMemo for derived objects like configs to avoid unnecessary re-renders).frontend/src/api/generated when available.refetchOnMount: false, staleTime) so behavior matches previous “single source” expectations.Before (Redux mirroring React Query):
if (getUserLatestVersionResponse.isFetched && getUserLatestVersionResponse.isSuccess && getUserLatestVersionResponse.data?.payload) {
dispatch({ type: UPDATE_LATEST_VERSION, payload: { latestVersion: getUserLatestVersionResponse.data.payload.tag_name } })
}
After (single source in React Query):
export function useAppStateHook() {
const { data, isError } = useQuery(...)
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
return {
latestVersion: data?.payload?.tag_name,
configs: memoizedConfigs,
isError,
}
}
Consumers use useAppStateHook() instead of useSelector or Context. Do not copy React Query result into Redux or Context.
When: State should be in the URL: filters, time range, pagination, selected values, view state. Keep payload small (e.g. Chrome ~2k chars); no large datasets or sensitive data.
Steps:
currentPage, timeRange, selectedFilter).useQueryState('param', parseAsString.withDefault('…')) (or parseAsInteger, etc.).useSearchParams encoding/decoding.Before (Context/Redux):
const { timeRange } = useContext(SomeContext)
const [page, setPage] = useDispatch(...)
After (nuqs):
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
When: State is global or cross-component client state: feature flags, dashboard state, query builder state, complex/large client objects (e.g. up to ~1.5–2MB). Not for server cache or local-only UI.
Steps:
DashboardStore, QueryBuilderStore). One create() per module; for large state use slice factories and combine.set (or setState / getState() + set) for updates; never mutate state directly.Selector (required):
const isLocked = useDashboardStore(state => state.isDashboardLocked)
Never use useStore() with no selector. Never do state.foo = x inside actions; use set(state => ({ ... })).
Before (Context/Redux):
const { isDashboardLocked, setLocked } = useContext(DashboardContext)
After (Zustand):
const isLocked = useDashboardStore(state => state.isDashboardLocked)
const setLocked = useDashboardStore(state => state.setLocked)
For large stores (many top-level fields), split into slices and combine:
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
Add eslint-plugin-zustand-rules with plugin:zustand-rules/recommended to enforce selectors and no direct mutation.
When: State is used only inside one component or a small subtree (form inputs, toggles, hover, panel selection). No URL sync, no cross-feature sharing.
Steps:
useState or useReducer (useReducer when multiple related fields change together).Do not use Zustand, Redux, or Context for purely local UI state.