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

SliceFilePersistedPurpose
authfrontend/features/login/state/authSlice.tsJWT access token + remembered email
loginFormfrontend/features/login/state/loginFormSlice.tsLogin form field values + submission state
themefrontend/redux/slices/themeSlice.tsUI colour mode (light / dark)
apifrontend/redux/api/baseApi.tsRTK 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 payload

User 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:

  1. Make the request
  2. 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()ProtectedRoute redirects to /login
  3. 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 typeWhere 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 componentsRedux slice
Form state local to one componentuseState
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.