Skip to content

Redux State Management Analysis

Analysis of how the arda-frontend-app uses Redux Toolkit, React-Redux, and Redux Persist to manage application state. This is an explanation-type document intended to inform the List View Component design by documenting the existing state management patterns.

LibraryVersionRole
@reduxjs/toolkit^2.11.2Core state management (slices, thunks, configureStore)
react-redux^9.2.0React bindings (Provider, hooks)
redux-persist^6.0.0Selective localStorage persistence and rehydration

The application does not use Redux Saga, RTK Query, or the legacy redux package directly.

The store is configured in src/store/store.ts using configureStore from Redux Toolkit.

configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
})
.concat(loggerMiddleware) // dev-only action logger
.concat(tokenRefreshMiddleware), // auto-refreshes expiring tokens
devTools: process.env.NODE_ENV === 'development',
});

Key decisions:

  • The serializable check ignores Redux Persist lifecycle actions.
  • Redux DevTools are enabled only in development.
  • RootState is derived from rootReducer (not store.getState) to avoid circular type references.
  • AppDispatch is exported for typed thunk dispatch.

The root reducer (src/store/rootReducer.ts) combines six slices, each created with createSlice.

SliceFilePurposePersisted Fields
authslices/authSlice.tsUser identity, tokens, JWT payload, session statetokens, user, userContext, jwtPayload
uislices/uiSlice.tsSidebar visibility, theme, preferencessidebarVisibility
itemsslices/itemsSlice.tsItem listings, editing, drafts, pagination, column visibilitycolumnVisibility, drafts, activeTab
scanslices/scanSlice.tsQR scanning state, filters, selectioncolumnVisibility, selectedFilters
orderQueueslices/orderQueueSlice.tsOrder queue counts, groups, selectionNone (ephemeral)
receivingslices/receivingSlice.tsReceiving tracking stateNone (ephemeral)

Each persisted slice uses a dedicated persistReducer() call with an explicit whitelist. Ephemeral UI state (loading, error, isRefreshing) is blacklisted so it resets on reload.

Persistence is configured per-slice in src/store/rootReducer.ts with redux-persist.

  • Storage backend: Conditional localStorage import (SSR-safe; falls back to no-op on the server).
  • Granularity: Each slice has its own persistConfig with a unique key (e.g., persist:auth, persist:items), enabling fine-grained control over what survives page reload.
  • Rehydration gate: The ReduxProvider component (src/store/ReduxProvider.tsx) wraps the app in <PersistGate loading={<Loader />}>, blocking render until rehydration completes.
  • Legacy migration: AuthInit (src/store/components/AuthInit.tsx) handles one-time migration from pre-Redux localStorage entries and re-validates tokens on hydration.

All asynchronous operations use createAsyncThunk. There is no use of sagas or raw middleware-based async flows.

Auth Thunks (src/store/thunks/authThunks.ts)

Section titled “Auth Thunks (src/store/thunks/authThunks.ts)”
ThunkPurpose
signInThunkCognito login; handles NEW_PASSWORD_REQUIRED challenge
respondToNewPasswordChallengeThunkCompletes forced password change
signOutThunkGlobal Cognito sign-out and local token clearing
refreshTokensThunkProactive token refresh; distinguishes transient vs. permanent failures
checkAuthThunkLocal JWT validation (no network call)
forgotPasswordThunkInitiates password reset flow
confirmNewPasswordThunkCompletes password reset
changePasswordThunkAuthenticated password change

Each thunk maps Cognito SDK errors to user-friendly messages and supports a mock-mode bypass (NEXT_PUBLIC_MOCK_MODE=true).

Order Queue Thunk (src/store/thunks/orderQueueThunks.ts)

Section titled “Order Queue Thunk (src/store/thunks/orderQueueThunks.ts)”

fetchOrderQueueCountThunk fetches counts from three Kanban endpoints in parallel.

Two custom middleware functions are registered after the default RTK middleware.

Development-only middleware that logs every action and the resulting state to the console using console.group().

Token Refresh (src/store/middleware/tokenRefreshMiddleware.ts)

Section titled “Token Refresh (src/store/middleware/tokenRefreshMiddleware.ts)”

Runs after every action (post-next(action)) and checks whether the access token is within 5 minutes of expiry. If so, it dispatches refreshTokensThunk. A 60-second cooldown prevents retry storms after transient failures, and permanent session expiry stops all further refresh attempts.

Selectors are organized into per-slice files under src/store/selectors/.

  • Simple selectors: Direct state lookups (e.g., selectUser, selectTokens, selectActiveTab).
  • Memoized selectors: Parameterized lookups using createSelector (e.g., selectItemById, selectColumnVisibilityForTab, selectDraft). These return stable empty-object/empty-array fallbacks to prevent unnecessary re-renders.

Approximately 70+ selectors are defined across six selector files.

src/store/hooks.ts exports typed wrappers that all components use instead of the bare react-redux hooks:

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
HookFileRole
useAuthhooks/useAuth.tsHigh-level auth API wrapping thunks (signIn, signOut, ensureValidTokens, etc.)
useOrderQueuehooks/useOrderQueue.tsAuto-fetches order queue count on mount when user/token changes
useSidebarVisibilityhooks/useSidebarVisibility.tsDispatches sidebar toggle actions
useJWThooks/useJWT.tsJWT-specific convenience selectors

Components never call useDispatch or useSelector directly; they use useAppDispatch, useAppSelector, or a domain hook.

The provider nesting order is defined in src/app/layout.tsx:

MSWInit (gates rendering until MSW worker starts in mock mode)
ReduxProvider
PersistGate (loading={<Loader />})
AuthInit (rehydration + legacy migration)
OrderQueueInit (auto-fetch on mount)
AppContent (routes and pages)

ReduxProvider (src/store/ReduxProvider.tsx) owns both the Redux <Provider> and the <PersistGate>.

Components follow a consistent pattern:

  1. Select state with a typed selector: const user = useAppSelector(selectUser);
  2. Dispatch actions with the typed dispatch: const dispatch = useAppDispatch();
  3. Use domain hooks for complex flows: const { signIn, loading, error } = useAuth();

Key integration points:

  • Auth guard: Checks selectIsAuthenticated to protect routes.
  • Sign-in page: Uses useAuth().signIn().
  • Sidebar: Dispatches toggleSidebarItem() via useSidebarVisibility.
  • Order queue badge: useOrderQueue() polls on init.
  • API calls: useAuth().ensureValidTokens() is called before fetch operations.

The List View Component design (Tiers 1–3b) lives in ux-prototype as a state-management-agnostic component library. It communicates entirely through props and callbacks (rowData, onSelectionChanged, paginationData, columnVisibility, etc.). Redux is an application-level concern in arda-frontend-app. The tiered architecture already assumes the consuming application owns the state, which is exactly what Redux does. The component design does not need to change.

  • Row data, loading, error — The items slice holds items, loading, error. These map directly to DataGridRuntimeConfig.rowData, .loading, .error. A selector feeds each prop.
  • Pagination — The items slice has pagination state. The component takes PaginationData plus onNextPage/onPreviousPage callbacks. Redux dispatch goes in the callbacks.
  • SelectionselectedItems lives in Redux. The component provides onSelectionChanged(rows). The handler dispatches to the slice.
  • Search and filtersearch and debouncedSearch are in the Redux items slice. The component’s hasActiveSearch (Tier 3b) and enableFiltering (Tier 3a) are complementary — external search stays in Redux, grid-level column filtering is internal to AG Grid.

Friction Point 1: Column Visibility Dual Persistence

Section titled “Friction Point 1: Column Visibility Dual Persistence”

The component library includes a useColumnPersistence hook that reads and writes column state directly to localStorage using a persistenceKey. Meanwhile, the items and scan Redux slices persist columnVisibility via redux-persist (which also writes to localStorage under persist:items / persist:scan).

When arda-frontend-app adopts the new DataGrid, both mechanisms would write column visibility to localStorage independently, potentially conflicting or drifting.

Resolution: Omit the persistenceKey prop at Tier 4 integration time and drive column visibility entirely through the columnVisibility prop on EntityGridViewProps. The useColumnPersistence hook activates only when persistenceKey is provided, so the two mechanisms cannot conflict. This keeps Redux as the single source of truth, matching the existing application pattern. The useColumnPersistence hook remains available for consumers that do not have their own state management layer.

Friction Point 2: Dirty Tracking vs. Redux drafts

Section titled “Friction Point 2: Dirty Tracking vs. Redux drafts”

The design proposes a useDirtyTracking<T>() hook that uses pure React state (local useState/useRef). The items slice in arda-frontend-app already persists drafts via Redux Persist, surviving page reloads.

The library hook would lose unsaved drafts on refresh unless the consuming application wires it to Redux. At Tier 4 integration time, each domain grid needs a Redux-backed alternative to the library hook.

Resolution: Each Tier 4 domain grid writes its own thin hook (~12 lines) that maps slice selectors and actions to the dirty tracking interface:

function useItemsDirtyTracking() {
const dispatch = useAppDispatch();
const drafts = useAppSelector(selectDrafts);
return {
drafts,
hasUnsavedChanges: Object.keys(drafts).length > 0,
setDraft: (id: string, changes: Partial<Item>) =>
dispatch(setItemDraft({ id, changes })),
discardAllDrafts: () => dispatch(clearAllItemDrafts()),
saveAllDrafts: () => { /* dispatch bulk save */ },
};
}

A generic factory hook was considered and rejected. The per-entity hook is so short that the abstraction overhead (configuration object, generic type threading, mental indirection) exceeds the line savings across the expected 2–4 entity types. Each entity’s dirty tracking may also diverge slightly (Items persist drafts across sessions; a future entity might not), and a concrete hook makes those divergences trivial to express.

ConcernIntegration PathFriction
Row data, loading, errorSelector to propNone
PaginationSelector to prop, dispatch in callbackNone
SelectiononSelectionChanged dispatches to sliceNone
Search and filterRedux owns external search; AG Grid owns column filtersNone
Column visibilityOmit persistenceKey; drive via columnVisibility prop from ReduxLow — design already supports this
Dirty trackingPer-entity Redux-backed hook (~12 lines each) replaces library hookLow — straightforward per-entity wiring

No changes are required to the Tier 1–3b component library design. Both friction points are resolved at Tier 4 integration time with minimal, per-entity wiring code.