Frontend State Management
The frontend uses Redux Toolkit for all shared state. Server data is fetched and cached via RTK Query (part of Redux Toolkit). Component-local state uses React’s useState / useReducer.
Redux Store Shape
store
├── api ← RTK Query cache (server state)
│ ├── queries
│ │ └── getIntegrations (cached integrations list)
│ └── mutations
│ └── login, logout, setPassword, changePassword, ...
├── theme ← 'light' | 'dark' (persisted to localStorage)
├── auth ← { token: string | null, rememberedEmail: string | null }
└── loginForm ← { email, password, rememberMe, isSubmitting, error }
Slices
| Slice | File | Persisted | Purpose |
|---|---|---|---|
auth | frontend/features/login/state/authSlice.ts | ✅ | JWT access token + remembered email |
loginForm | frontend/features/login/state/loginFormSlice.ts | ❌ | Login form field values + submission state |
theme | frontend/redux/slices/themeSlice.ts | ✅ | UI colour mode (light / dark) |
api | frontend/redux/api/baseApi.ts | ❌ | RTK Query cache (cleared on page reload) |
Auth Slice
// State shape
interface AuthState {
token: string | null; // JWT access token; null = unauthenticated
rememberedEmail: string | null;
}
// Actions
setToken(token: string) // store access token after login / refresh
clearToken() // on logout or failed refresh → redirect to /login
setRememberedEmail(email) // "remember me" checkbox
// Selectors
selectIsAuthenticated(state) // token !== null
selectUserRole(state) // decoded from JWT payload
selectUserName(state) // decoded from JWT payload
selectUserEmail(state) // decoded from JWT payloadUser identity (role, name, email) is decoded directly from the JWT payload on the client — no separate /api/me call required.
RTK Query API Layer
All server-state API calls go through baseApi in frontend/redux/api/baseApi.ts.
Feature endpoints are injected:
baseApi (createApi with re-auth baseQuery)
├── authApi.injectEndpoints(...)
│ login, logout, setPassword, changePassword, refreshToken
└── integrationsApi.injectEndpoints(...)
getIntegrations, toggleIntegration
Re-auth Interceptor
Every request goes through baseQueryWithReauth:
- Make the request
- If the response is 401 (
type: 'unauthorized'):- Fire
POST /api/auth/refresh(the HttpOnly cookie is sent automatically) - On success: dispatch
setToken(newToken)→ retry the original request - On failure: dispatch
clearToken()→ProtectedRouteredirects to/login
- Fire
- Concurrency guard: if multiple requests fail with 401 simultaneously, only the first triggers a refresh. Subsequent requests wait on a shared
refreshPromise.
Adding a New API
// frontend/redux/api/myFeatureApi.ts
import { baseApi } from './baseApi';
const myApi = baseApi.injectEndpoints({
endpoints: (build) => ({
getThings: build.query<Thing[], void>({
query: () => 'things',
transformResponse: (res: ApiSuccessResponse<Thing[]>) => res.data,
providesTags: ['Thing'],
}),
createThing: build.mutation<Thing, NewThing>({
query: (body) => ({ url: 'things', method: 'POST', body }),
invalidatesTags: ['Thing'],
}),
}),
});
export const { useGetThingsQuery, useCreateThingMutation } = myApi;Local vs Global State Rules
| State type | Where to put it |
|---|---|
| Server data (fetched from API) | RTK Query (baseApi) |
| Auth token (must survive page refresh) | auth slice (persisted) |
| UI preferences (must survive page refresh) | theme slice (persisted) |
| Form state shared across components | Redux slice |
| Form state local to one component | useState |
| Ephemeral UI state (open/close, hover) | useState |
localStorage Persistence
localStorageMiddleware runs after every Redux action and writes whitelisted slices to localStorage under the key redux_state:
const PERSISTED_KEYS = ['theme', 'auth'] as const;On startup, each persisted slice calls loadSliceState(key, fallback) in its initialState:
const initialState: AuthState = loadSliceState('auth', {
token: null,
rememberedEmail: null,
});This means the access token and theme survive a page reload without a server round-trip.
Loading States
Prefer skeleton loaders over spinners for data-loading states. Ready-made skeletons are in frontend/shared/components/skeleton.tsx:
import {
TableSkeleton,
ListSkeleton,
CardSkeleton,
} from '@frontend/shared/components/skeleton';
const { data, isLoading } = useGetThingsQuery();
if (isLoading) return <TableSkeleton rows={5} cols={3} />;Button loading states use MUI’s loading prop:
<Button loading={isSubmitting} type="submit">
Save
</Button>Error Boundary
The app root is wrapped in <ErrorBoundary> (frontend/shared/components/errorBoundary.tsx). Sections that should fail independently (without crashing the whole page) should be wrapped in their own <ErrorBoundary>.
Routing
/login → LoginPage (public)
/ → HomePage (protected)
/integrations → IntegrationsPage (protected)
/set-password → handled by LoginPage logic with signup token
/* → NotFoundPage (protected fallback)
ProtectedRoute checks selectIsAuthenticated. If false, it redirects to /login. AuthProvider silently calls POST /api/auth/refresh on app mount to restore the session if the access token has expired but the refresh cookie is still valid.