Add or change client state in src/frontend/web/store — Redux Toolkit slices, store registration, typed useAppSelector/useAppDispatch hooks, the auth/tenant session state, and middleware. Use when you need state that is not server data, and to decide between a slice and the RTK Query cache.
Instal·la
npx skillscat add ma496/easy-for-net/redux-state Instal·la des del registre de SkillsCat.
Client state
Slice or RTK Query?
Server data belongs in the RTK Query cache — never copy a query result into a slice "so components
can read it". Use a slice only for state the client owns:
| Slice (store key) | Holds |
|---|---|
authSlice (auth) |
the session: user (GetUserInfoResponse), isAuthenticated, activeTenant, tenants — typed as AuthState from @/lib/utils |
themeConfigSlice (theme) |
the color scheme (theme: light, dark or system, and isDarkMode, what it resolves to), rtlClass, and sidebar (collapsed to the icon rail from lg up, the drawer open below it). Enabled languages come from the translation dictionary, not a slice |
notificationsSlice (notifications) |
unreadCount, the badge (setUnreadCount, and notificationReceived counting one push) |
serviceAvailabilitySlice (serviceAvailability) |
isUnavailable — whether the API is unreachable |
notificationsSlice is the sanctioned exception to the rule above: useNotificationHub subscribes
to the unread-count query (polling it only while the hub is down), mirrors the number into the slice
and counts each pushed notification on top, so the badge can be read from anywhere without every
consumer subscribing to the query. authSlice is the other: the account info is
server data, but it is the session every guard reads, so it is held in a slice and replaced
wholesale by setUserInfo.
Adding a slice
store/slices/<name>.ts — existing files mix camelCaseSlice.ts (authSlice.ts,notificationsSlice.ts, themeConfigSlice.tsx) and kebab-case (service-availability-slice.ts);
prefer kebab-case for a new file:
import { createSlice } from '@reduxjs/toolkit'
interface ServiceAvailabilityState {
isUnavailable: boolean
}
const initialState: ServiceAvailabilityState = { isUnavailable: false }
/**
* Tracks whether the backend API is unreachable so the app shell can show
* an in-place service-unavailable screen without changing the current URL.
*/
export const serviceAvailabilitySlice = createSlice({
name: 'serviceAvailability',
initialState,
reducers: {
showServiceUnavailable(state) {
state.isUnavailable = true
},
clearServiceUnavailable(state) {
state.isUnavailable = false
},
},
})
export const { showServiceUnavailable, clearServiceUnavailable } = serviceAvailabilitySlice.actionsThen:
- Re-export the slice and its actions from
store/slices/index.ts. - Register it in
store/index.tsxkeyed byslice.name:
export const store = configureStore({
reducer: {
[themeConfigSlice.name]: themeConfigSlice.reducer,
[appApi.reducerPath]: appApi.reducer,
[authSlice.name]: authSlice.reducer,
[notificationsSlice.name]: notificationsSlice.reducer,
[serviceAvailabilitySlice.name]: serviceAvailabilitySlice.reducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(rtkErrorMiddleware, appApi.middleware),
devTools: process.env.NODE_ENV !== 'production',
})RootState and AppDispatch are inferred from the store, so a new slice is immediately typed.
Reducers must stay deterministic and side-effect free. Persistence and DOM work (writinglocalStorage, toggling the dark class, setting dir on <html>) happen in App.tsx / provider
components reacting to state — themeConfigSlice is the model to follow. setLocalStorageValue /getLocalStorageValue from @/lib/utils wrap JSON persistence for new values.
Reading and dispatching
Always the typed hooks from @/store/hooks, never bare useSelector/useDispatch:
const authState = useAppSelector((state) => state.auth)
const hasActiveTenant = useAppSelector((state) => state.auth.activeTenant !== undefined)
const isRTL = useAppSelector((state) => state.theme.rtlClass) === 'rtl'
const dispatch = useAppDispatch()
dispatch(setUnreadCount(count))Select the narrowest value a component needs so unrelated updates do not re-render it.
Session and tenant state
App.tsxreads/account/get-infoon mount and dispatchessetUserInfo, which derivesisAuthenticated,activeTenantandtenantsfrom the payload — never set those fields on their
own.baseQueryWithReauthre-reads the info after every successful token refresh (a renewal can
drop a suspended or left tenant), and dispatchessignout()then redirects to/signin?redirect=…when the refresh fails.- The acting tenant never changes in place. Each host is one tenant with its own session, so
a document's store only ever holds one tenant's data; another tenant is a link to its address
(tenant.urlfrom get-info), opened in a new tab. Do not write a switch path; seemulti-tenancy. - Ending the session (sign-out, password change) calls
leaveSignedOut(signinHref)— a full page
load, so the next user of the browser starts with a fresh store. - Permission checks read this slice through
isAllowed(authState, [Allow.X])and scope checks throughisPathAvailable(authState.user, path)— see thepermissionsskill.
Middleware
store/middlewares/rtk-error-middleware.ts inspects every rejected RTK Query action and dispatchesshowServiceUnavailable() on a FETCH_ERROR (the API is unreachable), which rendersServiceUnavailableView. Every other failure stays the calling screen's business — it never signs the
user out or redirects. Add cross-cutting reactions to API outcomes there rather than repeating them in
components:
export const rtkErrorMiddleware: Middleware = (api) => (next) => (action: unknown) => {
if (isRejectedWithValue(action)) { /* inspect payload/status, dispatch */ }
return next(action)
}Export new middleware from store/middlewares/index.ts and add it to the concat(...) chain
before appApi.middleware. Reducers, the middleware and the sign-out page load all have
colocated Vitest tests (authSlice.test.ts, rtk-error-middleware.test.ts, tenant-cache.test.ts) —
extend them; see frontend-tests.
Checklist
- The data is genuinely client-owned (otherwise use RTK Query)
- Slice file + export from
store/slices/index.ts - Registered in
store/index.tsxbyslice.name - Reducers side-effect free; persistence handled outside
- Consumers use
useAppSelector/useAppDispatchwith narrow selectors - Reducer/middleware behaviour pinned in a colocated
*.test.ts