Frontend Architecture
Framework: React 18 + Vite Language: JavaScript/TypeScript (mixed) Styling: TailwindCSS + Radix UI State: React Query + Context API Routing: React Router v7 Authentication: Keycloak (OAuth2/OIDC)
Table of Contents
- Overview
- Technology Stack
- Project Structure
- Component Architecture
- State Management
- Routing & Navigation
- Authentication Flow
- Data Fetching Patterns
- Styling Conventions
- Forms & Validation
- Admin Dashboard Architecture
- Performance Optimizations
- Build Configuration
Overview
The Noumaris frontend is a single-page application (SPA) built with React 18 and Vite. It serves two primary user groups:
- Clinical Users (Residents) - Clinical documentation and scribe features
- Admin Users (Institution Admins, Superadmins) - Institution management, permissions, analytics
The application prioritizes:
- Fast development (Vite HMR in <50ms)
- Type safety (gradual migration from JS to TS)
- Accessibility (Radix UI primitives)
- Performance (code splitting, lazy loading)
- Security (Keycloak OAuth2, JWT tokens)
Technology Stack
Core Framework
- React 18.3.1 - UI library with concurrent features
- Vite 6.0.5 - Build tool (100x faster HMR than Webpack)
- React Router v7.7.1 - Client-side routing
- TypeScript 5.9.3 - Gradual type adoption
UI Components
- Radix UI - Accessible, unstyled component primitives
- Dialog, Dropdown Menu, Select, Tabs, Accordion, Tooltip, etc.
- TailwindCSS 3.4.1 - Utility-first CSS framework
- Lucide React 0.474.0 - Icon library
- Sonner 2.0.7 - Toast notifications
State Management
- React Query 5.90.3 - Server state management
- React Context - Global UI state (auth, permissions)
- useState/useReducer - Local component state
Data Handling
- React Hook Form 7.65.0 - Form state management
- Zod 3.25.76 - Schema validation
- TipTap 3.3.1 - Rich text editor (clinical notes)
Authentication
- Keycloak JS 26.2.0 - OAuth2/OIDC client
- Keycloakify 11.9.6 - Custom Keycloak theme builder
Development Tools
- ESLint 9.17.0 - Linting
- React Query DevTools - State debugging
- Vite DevTools - Build analysis
Project Structure
frontend/src/
├── components/
│ ├── layout/
│ │ ├── MainLayout.jsx # Clinical shell
│ │ └── Sidebar.jsx # Navigation for clinicians
│ ├── templates/
│ │ ├── TemplateEditor.tsx
│ │ └── TemplateList.tsx
│ ├── ui/ # shadcn-inspired primitives
│ │ ├── accordion.tsx
│ │ ├── alert-dialog.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── input.tsx
│ │ ├── select.tsx
│ │ ├── tabs.tsx
│ │ ├── tooltip.tsx
│ │ ├── AllSessionsModal.jsx
│ │ ├── GeneratedNotePanel.jsx
│ │ ├── SessionWarningModal.jsx
│ │ └── TranscriptionButton.jsx
│ └── views/
│ ├── DoctorDashboard.tsx
│ ├── TemplateManagerPage.tsx
│ ├── LoginPage.jsx
│ └── LogoutPage.jsx
├── features/
│ └── admin/
│ ├── shared/
│ │ ├── components/
│ │ │ ├── ConfirmDialog.tsx
│ │ │ ├── DataTable.tsx
│ │ │ ├── EmptyState.tsx
│ │ │ ├── LoadingSkeleton.tsx
│ │ │ ├── StatusBadge.tsx
│ │ │ ├── PaginationControls.jsx
│ │ │ ├── SearchFilter.jsx
│ │ │ └── RequireRole.jsx
│ │ ├── layouts/
│ │ │ ├── AdminLayout.tsx
│ │ │ └── AdminSidebar.tsx
│ │ └── pages/
│ │ └── AdminInvitationPage.jsx
│ ├── superadmin/
│ │ ├── components/
│ │ │ ├── CreateInstitutionModal.jsx
│ │ │ ├── EditInstitutionModal.jsx
│ │ │ ├── FeatureAccessMatrix.jsx
│ │ │ ├── InstitutionAdminInvitationList.jsx
│ │ │ ├── InstitutionAdminList.jsx
│ │ │ ├── InstitutionListTable.jsx
│ │ │ ├── InstitutionQuickSwitcher.jsx
│ │ │ └── InstitutionSettingsForm.jsx
│ │ ├── context/
│ │ │ └── SuperadminInstitutionContext.tsx
│ │ └── pages/
│ │ ├── SuperadminDashboard.jsx
│ │ ├── InstitutionDetailPage.jsx
│ │ ├── InstitutionLandingRedirect.jsx
│ │ └── SystemAnalyticsDashboard.jsx
│ └── institution/
│ ├── components/
│ │ ├── InviteResidentModal.jsx
│ │ ├── PermissionsMatrix.jsx
│ │ └── ResidentListTable.jsx
│ └── pages/
│ ├── InstitutionAdminDashboard.jsx
│ ├── ResidentPermissionsPage.jsx
│ ├── UsageMetricsPage.jsx
│ └── AuditLogPage.jsx
├── hooks/
│ ├── useDebounce.ts
│ ├── useDocuments.ts
│ ├── useScraping.js
│ ├── useSearch.js
│ ├── useSystemInfo.js
│ ├── useUpload.js
│ └── useUserRole.tsx
├── context/
│ ├── AuthContext.tsx
│ └── PermissionsContext.jsx # Exports usePermissions helpers
├── lib/
│ ├── api/
│ │ ├── admin.ts
│ │ ├── doctor.ts
│ │ ├── invitations.ts
│ │ └── templates.ts
│ ├── api.js # Legacy helpers still used by JS entry points
│ ├── queryClient.ts
│ └── utils.ts
├── types/
│ ├── admin.ts
│ ├── auth.ts
│ ├── doctor.ts
│ ├── invitations.ts
│ └── templates.ts
├── config/
│ └── api.js
├── keycloak-theme/
│ ├── kc.gen.tsx
│ └── login/
│ ├── KcPage.jsx
│ ├── KcContext.jsx
│ └── pages/
│ ├── Login.jsx
│ └── Register.jsx
├── App.jsx
├── main.jsx
├── keycloak.js
└── index.cssThe module also exports strongly typed query key factories (adminKeys) and helpers such as invalidateAdminQueries so feature modules do not hardcode string literals.
TypeScript Migration Status
The frontend is midway through a JavaScript → TypeScript conversion. Admin shared primitives, the superadmin context, and all REST clients (frontend/src/lib/api/*.ts) now ship with strict typing, while legacy dashboards and authentication shells remain in .jsx until rewritten. New modules should default to .ts/.tsx, and outstanding conversions are tracked in frontend/ts-migration-notes.md.
Component Architecture
Component Hierarchy
graph TD
App[App.jsx] --> AuthContext[AuthContext Provider]
AuthContext --> QueryClientProvider[React Query Provider]
QueryClientProvider --> Routes[React Router Routes]
Routes --> PublicRoutes[Public Routes]
PublicRoutes --> LoginPage[LoginPage]
PublicRoutes --> LogoutPage[LogoutPage]
PublicRoutes --> AdminInvitation[AdminInvitationPage]
Routes --> ClinicalRoutes[Clinical Routes]
ClinicalRoutes --> MainLayout[MainLayout]
MainLayout --> Sidebar[Sidebar]
MainLayout --> DoctorDashboard[DoctorDashboard]
MainLayout --> TemplateManager[TemplateManagerPage]
Routes --> AdminRoutes[Admin Routes]
AdminRoutes --> AdminLayout[AdminLayout]
AdminLayout --> SuperadminRoutes[Superadmin Routes]
AdminLayout --> InstitutionRoutes[Institution Admin Routes]
SuperadminRoutes --> SuperadminDashboard[SuperadminDashboard]
SuperadminRoutes --> InstitutionDetail[InstitutionDetailPage]
SuperadminRoutes --> Analytics[SystemAnalyticsDashboard]
InstitutionRoutes --> InstitutionDashboard[InstitutionAdminDashboard]
InstitutionRoutes --> Permissions[ResidentPermissionsPage]
InstitutionRoutes --> Usage[UsageMetricsPage]Component Patterns
1. Page Components (views/)
- Top-level route components
- Handle data fetching with React Query
- Compose smaller components
Example: DoctorDashboard.tsx
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '@/context/AuthContext';
export default function DoctorDashboard({ currentEncounterId, setCurrentEncounterId }) {
const { user } = useAuth();
// Fetch sessions with React Query
const { data: sessions } = useQuery({
queryKey: ['sessions'],
queryFn: () => fetchSessions(user.token)
});
return (
<div className="p-6">
{/* Compose smaller components */}
<SessionList sessions={sessions} />
<NoteEditor encounterId={currentEncounterId} />
</div>
);
}2. Layout Components (components/layout/)
- Manage page structure and navigation
- Handle global state (sidebar collapse, theme)
Example: MainLayout.jsx
const MainLayout = () => {
const { user } = useAuth();
const [activeView, setActiveView] = useState('dashboard');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
return (
<div className="flex h-screen">
<Sidebar
activeView={activeView}
setActiveView={setActiveView}
isCollapsed={isSidebarCollapsed}
setIsCollapsed={setIsSidebarCollapsed}
/>
<main className="flex-1 overflow-y-auto">
{renderContent()}
</main>
</div>
);
};3. UI Components (components/ui/)
- Reusable, low-level components
- Based on shadcn/ui pattern (Radix + Tailwind)
- Composable with compound components
Example: Button.tsx
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);4. Feature Components (features/)
- Domain-specific components
- Encapsulate business logic
- May include own context providers
Example: SuperadminInstitutionContext.tsx
import { createContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useQuery, type QueryObserverResult } from '@tanstack/react-query';
import { useAuth } from '@/context/AuthContext';
import { useUserRole } from '@/hooks/useUserRole';
import { getInstitutions } from '@/lib/api/admin';
import type { Institution, InstitutionListResponse } from '@/types/admin';
interface SuperadminInstitutionContextValue {
institutions: Institution[];
selectedInstitutionId: number | null;
selectInstitution: (id: number | null) => void;
isLoading: boolean;
refetch: () => Promise<QueryObserverResult<InstitutionListResponse, Error>>;
}
const SuperadminInstitutionContext = createContext<SuperadminInstitutionContextValue | null>(null);
export function SuperadminInstitutionProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const { isSuperadmin } = useUserRole();
const [selectedInstitutionId, setSelectedInstitutionId] = useState<number | null>(null);
const { data, isLoading, refetch } = useQuery<InstitutionListResponse, Error>({
queryKey: ['superadmin', 'institutions'],
queryFn: () =>
getInstitutions({ skip: 0, limit: 1000 }, user?.token),
enabled: isSuperadmin && !!user?.token,
});
useEffect(() => {
const list = data?.institutions ?? [];
if (!list.length) {
setSelectedInstitutionId(null);
return;
}
const hasSelection = list.some((institution) => institution.id === selectedInstitutionId);
if (!hasSelection) {
setSelectedInstitutionId(list[0].id);
}
}, [data, selectedInstitutionId]);
const value = useMemo<SuperadminInstitutionContextValue>(() => {
const institutions = data?.institutions ?? [];
const activeId = selectedInstitutionId ?? institutions[0]?.id ?? null;
return {
institutions,
selectedInstitutionId: activeId,
selectInstitution: setSelectedInstitutionId,
isLoading,
refetch,
};
}, [data, isLoading, refetch, selectedInstitutionId]);
return (
<SuperadminInstitutionContext.Provider value={value}>
{children}
</SuperadminInstitutionContext.Provider>
);
}State Management
1. Server State (React Query)
Used for all API data (sessions, templates, institutions, etc.).
Configuration:
// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
const retryDelay = (attemptIndex: number) =>
Math.min(1000 * 2 ** attemptIndex, 30_000);
const shouldRetry = (failureCount: number, error: unknown) => {
if (failureCount >= 3) return false;
if (typeof error === 'object' && error && 'status' in error) {
const status = (error as { status?: number }).status;
if (typeof status === 'number' && status >= 400 && status < 500 && status !== 429) {
return false;
}
}
return true;
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60 * 1000,
retry: shouldRetry,
retryDelay,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: false,
},
mutations: {
retry: false,
onError: (error) => {
console.error('Mutation error:', error);
},
},
},
});Usage Pattern:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch data
const { data, isLoading, error } = useQuery({
queryKey: ['sessions'],
queryFn: () => fetch('/api/sessions', {
headers: { Authorization: `Bearer ${token}` }
}).then(res => res.json())
});
// Mutate data
const queryClient = useQueryClient();
const createSession = useMutation({
mutationFn: (data) => fetch('/api/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(data)
}),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['sessions'] });
}
});Benefits:
- Automatic caching
- Background refetching
- Optimistic updates
- Request deduplication
2. Authentication State (Context)
AuthContext.tsx:
interface AuthContextValue {
authStatus: 'initializing' | 'authenticated' | 'unauthenticated';
user: AuthUser | null;
login: () => void;
logout: () => void;
register: () => void;
isWarningModalOpen: boolean;
countdown: number;
stayLoggedIn: () => void;
}
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [authStatus, setAuthStatus] = useState<AuthStatus>('initializing');
const [user, setUser] = useState<AuthUser | null>(null);
// Keycloak initialization
useEffect(() => {
keycloak.init({ onLoad: 'check-sso' })
.then((authenticated) => {
if (authenticated) {
const tokenParsed = keycloak.tokenParsed;
setUser({
id: tokenParsed?.sub,
username: tokenParsed?.preferred_username,
email: tokenParsed?.email,
token: keycloak.token,
roles: tokenParsed?.realm_access?.roles ?? []
});
setAuthStatus('authenticated');
} else {
setAuthStatus('unauthenticated');
}
});
}, []);
// Auto token refresh every 60 seconds
useEffect(() => {
if (authStatus !== 'authenticated') return;
const interval = setInterval(() => {
keycloak.updateToken(70) // Refresh if expires in <70s
.then((refreshed) => {
if (refreshed) {
setUser(prev => ({ ...prev, token: keycloak.token }));
}
})
.catch(() => logout());
}, 60 * 1000);
return () => clearInterval(interval);
}, [authStatus]);
return (
<AuthContext.Provider value={{ authStatus, user, login, logout, register }}>
{children}
</AuthContext.Provider>
);
};Session Timeout:
- Inactivity timer: 27 minutes
- Warning modal: 2 minutes before logout
- Auto-logout at 29 minutes
- User can extend session
// Inactivity detection
const resetTimers = useCallback(() => {
clearInactivityTimer();
inactivityTimer.current = setTimeout(() => {
setIsWarningModalOpen(true); // Show warning at 27 min
warningTimer.current = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
logout(); // Auto logout at 0
return 0;
}
return prev - 1;
});
}, 1000);
}, 27 * 60 * 1000);
}, [logout]);
useEffect(() => {
window.addEventListener('mousemove', resetTimers);
window.addEventListener('keypress', resetTimers);
return () => {
window.removeEventListener('mousemove', resetTimers);
window.removeEventListener('keypress', resetTimers);
};
}, [resetTimers]);3. Permissions State (Context)
PermissionsContext.jsx:
const PermissionsContext = createContext();
export const PermissionsProvider = ({ children }) => {
const { user } = useAuth();
const [permissions, setPermissions] = useState(null);
// Fetch user permissions
useEffect(() => {
if (!user?.token) return;
fetch('/api/user/permissions', {
headers: { Authorization: `Bearer ${user.token}` }
})
.then(res => res.json())
.then(setPermissions);
}, [user]);
const hasPermission = (featureName) => {
return permissions?.[featureName]?.enabled ?? false;
};
return (
<PermissionsContext.Provider value={{ permissions, hasPermission }}>
{children}
</PermissionsContext.Provider>
);
};4. Local Component State (useState/useReducer)
For UI-only state:
- Sidebar collapse
- Modal open/close
- Form inputs
- Active tab
Example:
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('general');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);Routing & Navigation
React Router v7 Configuration
App.jsx:
import { Routes, Route, Navigate } from 'react-router-dom';
export default function App() {
const { authStatus } = useAuth();
const { getPrimaryRole } = useUserRole();
if (authStatus === 'initializing') {
return <Loader />; // Full-screen loader
}
const isAuthenticated = authStatus === 'authenticated';
const primaryRole = isAuthenticated ? getPrimaryRole() : null;
return (
<Routes>
{/* Public routes */}
<Route path="/login" element={
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/admin-invite/:token" element={<AdminInvitationPage />} />
<Route path="/403" element={<ForbiddenPage />} />
{/* Admin redirect */}
<Route path="/admin" element={
isAuthenticated ? <AdminRedirect /> : <Navigate to="/login" replace />
} />
{/* Superadmin routes */}
<Route path="/admin/superadmin/*" element={
<RequireRole role={ROLES.SUPERADMIN}>
<AdminLayout />
</RequireRole>
}>
<Route index element={<SuperadminDashboard />} />
<Route path="institutions/:id" element={<InstitutionDetailPage />} />
<Route path="analytics" element={<SystemAnalyticsDashboard />} />
</Route>
{/* Institution admin routes */}
<Route path="/admin/institution/*" element={
<RequireRole role={ROLES.INSTITUTION_ADMIN}>
<AdminLayout />
</RequireRole>
}>
<Route index element={<InstitutionAdminDashboard />} />
<Route path="permissions" element={<ResidentPermissionsPage />} />
<Route path="usage" element={<UsageMetricsPage />} />
<Route path="audit-log" element={<AuditLogPage />} />
</Route>
{/* Clinical scribe routes */}
<Route path="/*" element={
isAuthenticated ? (
// Redirect admins to their dashboard
primaryRole === ROLES.SUPERADMIN ? (
<Navigate to="/admin/superadmin" replace />
) : primaryRole === ROLES.INSTITUTION_ADMIN ? (
<Navigate to="/admin/institution" replace />
) : (
<MainLayout />
)
) : (
<Navigate to="/login" replace />
)
} />
</Routes>
);
}Protected Routes Pattern
RequireRole.jsx:
import { Navigate } from 'react-router-dom';
import { useUserRole } from '@/hooks/useUserRole';
export default function RequireRole({ role, children }) {
const { hasRole } = useUserRole();
if (!hasRole(role)) {
return <Navigate to="/403" replace />;
}
return children;
}Role-Based Redirect
AdminRedirect.jsx:
import { Navigate } from 'react-router-dom';
import { useUserRole, ROLES } from '@/hooks/useUserRole';
export default function AdminRedirect() {
const { getPrimaryRole } = useUserRole();
const primaryRole = getPrimaryRole();
if (primaryRole === ROLES.SUPERADMIN) {
return <Navigate to="/admin/superadmin" replace />;
}
if (primaryRole === ROLES.INSTITUTION_ADMIN) {
return <Navigate to="/admin/institution" replace />;
}
return <Navigate to="/" replace />;
}Authentication Flow
1. Keycloak Initialization
keycloak.js:
import Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL,
realm: import.meta.env.VITE_KEYCLOAK_REALM,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
});
export default keycloak;2. Authentication States
initializing: Checking Keycloak session (SSO)authenticated: User logged in, token validunauthenticated: No session, redirect to login
3. Login Flow
sequenceDiagram
participant User
participant Frontend
participant Keycloak
participant Backend
User->>Frontend: Visit app
Frontend->>Keycloak: Init with check-sso
Keycloak-->>Frontend: No session
Frontend->>Frontend: authStatus = 'unauthenticated'
Frontend->>User: Redirect to /login
User->>Frontend: Click "Sign In"
Frontend->>Keycloak: login()
Keycloak->>User: Show login page (custom theme)
User->>Keycloak: Submit credentials
Keycloak-->>Frontend: Return with tokens
Frontend->>Frontend: Parse token, extract user info
Frontend->>Frontend: authStatus = 'authenticated'
Frontend->>Backend: API call with JWT
Backend->>Keycloak: Validate token
Keycloak-->>Backend: Valid
Backend-->>Frontend: Response4. Token Management
Token Refresh:
useEffect(() => {
if (authStatus !== 'authenticated') return;
const interval = setInterval(() => {
keycloak.updateToken(70) // Refresh if <70s remaining
.then((refreshed) => {
if (refreshed) {
console.log('Token refreshed');
setUser(prev => ({ ...prev, token: keycloak.token }));
}
})
.catch(() => {
console.error('Failed to refresh token');
logout();
});
}, 60 * 1000); // Check every 60s
return () => clearInterval(interval);
}, [authStatus]);Token in API Calls:
fetch('/api/sessions', {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
}
})Data Fetching Patterns
Pattern 1: Simple Query
import { useQuery } from '@tanstack/react-query';
function SessionList() {
const { user } = useAuth();
const { data: sessions, isLoading, error } = useQuery({
queryKey: ['sessions'],
queryFn: async () => {
const res = await fetch('/api/sessions', {
headers: { Authorization: `Bearer ${user.token}` }
});
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
enabled: !!user?.token, // Only run if token exists
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{sessions.map(s => <SessionCard key={s.id} {...s} />)}</div>;
}Pattern 2: Mutation with Optimistic Update
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateSessionButton() {
const { user } = useAuth();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newSession) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${user.token}`
},
body: JSON.stringify(newSession)
});
return res.json();
},
// Optimistic update
onMutate: async (newSession) => {
await queryClient.cancelQueries({ queryKey: ['sessions'] });
const previousSessions = queryClient.getQueryData(['sessions']);
queryClient.setQueryData(['sessions'], (old) => [
...old,
{ ...newSession, id: 'temp', status: 'creating' }
]);
return { previousSessions };
},
onError: (err, newSession, context) => {
queryClient.setQueryData(['sessions'], context.previousSessions);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
}
});
return (
<Button onClick={() => mutation.mutate({ title: 'New Session' })}>
{mutation.isPending ? 'Creating...' : 'New Session'}
</Button>
);
}Pattern 3: Dependent Queries
function InstitutionDetail({ institutionId }) {
// Fetch institution
const { data: institution } = useQuery({
queryKey: ['institution', institutionId],
queryFn: () => fetchInstitution(institutionId)
});
// Fetch residents (depends on institution)
const { data: residents } = useQuery({
queryKey: ['residents', institutionId],
queryFn: () => fetchResidents(institutionId),
enabled: !!institution, // Only fetch if institution exists
});
return <div>...</div>;
}Pattern 4: Pagination
function PaginatedList() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['items', page],
queryFn: () => fetchItems({ page, limit: 20 }),
keepPreviousData: true, // Show old data while fetching new page
});
return (
<div>
{data?.items.map(item => <ItemCard key={item.id} {...item} />)}
<Pagination
currentPage={page}
totalPages={data?.totalPages}
onPageChange={setPage}
/>
</div>
);
}Styling Conventions
TailwindCSS Utility Classes
Standard patterns:
// Layout
<div className="flex items-center justify-between gap-4">
<div className="grid grid-cols-3 gap-6">
// Spacing
<div className="p-6 space-y-4"> {/* padding + vertical spacing */}
<div className="mt-8 mb-4">
// Typography
<h1 className="text-2xl font-bold text-foreground">
<p className="text-sm text-muted-foreground">
// Colors (use CSS variables)
<div className="bg-background text-foreground">
<div className="bg-primary text-primary-foreground">
<div className="border border-border">
// Responsive
<div className="flex flex-col md:flex-row">
<div className="w-full lg:w-1/2">
// Dark mode
<div className="bg-white dark:bg-[#1b1d1c]">
<p className="text-gray-900 dark:text-gray-100">Component Styling with CVA
Class Variance Authority (CVA) for variant-based styles:
import { cva, type VariantProps } from 'class-variance-authority';
const cardVariants = cva(
"rounded-lg border p-4", // Base styles
{
variants: {
variant: {
default: "bg-card text-card-foreground",
primary: "bg-primary text-primary-foreground",
destructive: "bg-destructive text-destructive-foreground",
},
size: {
sm: "p-2",
md: "p-4",
lg: "p-6",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
}
);
export const Card = ({ variant, size, className, ...props }) => (
<div className={cn(cardVariants({ variant, size, className }))} {...props} />
);CSS Variables (index.css)
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--border: 214.3 31.8% 91.4%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
/* ... */
}
}Utility Function: cn()
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Usage:
<div className={cn(
"base-class",
isActive && "active-class",
className // Allow prop override
)} />Forms & Validation
React Hook Form + Zod
Example: Create Institution Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Define schema
const institutionSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
maxResidents: z.number().min(1).max(10000),
maxAdmins: z.number().min(1).max(100),
});
function CreateInstitutionForm({ onSubmit }) {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
resolver: zodResolver(institutionSchema),
defaultValues: {
name: '',
maxResidents: 50,
maxAdmins: 5,
}
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="name">Institution Name</Label>
<Input id="name" {...register('name')} />
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
</div>
<div>
<Label htmlFor="maxResidents">Max Residents</Label>
<Input
id="maxResidents"
type="number"
{...register('maxResidents', { valueAsNumber: true })}
/>
{errors.maxResidents && (
<p className="text-sm text-destructive">{errors.maxResidents.message}</p>
)}
</div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Institution'}
</Button>
</form>
);
}Admin Dashboard Architecture
Two-Tier Admin System
Superadmin - System-wide management
- Create/edit/delete institutions
- Manage institution admins
- System analytics
- Feature access matrix
Institution Admin - Institution-level management
- Invite/manage residents
- Configure resident permissions
- View usage metrics
- Audit logs
Feature: Feature Access Matrix
Superadmins can enable/disable features for each institution:
function FeatureAccessMatrix({ institutionId }) {
const { data: features } = useQuery({
queryKey: ['features'],
queryFn: fetchFeatures
});
const { data: institutionFeatures } = useQuery({
queryKey: ['institution-features', institutionId],
queryFn: () => fetchInstitutionFeatures(institutionId)
});
const toggleMutation = useMutation({
mutationFn: ({ featureId, enabled }) =>
updateInstitutionFeature(institutionId, featureId, enabled),
onSuccess: () => {
queryClient.invalidateQueries(['institution-features', institutionId]);
}
});
return (
<table>
<thead>
<tr>
<th>Feature</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
{features?.map(feature => (
<tr key={feature.id}>
<td>{feature.name}</td>
<td>
<Switch
checked={institutionFeatures?.[feature.id]?.enabled ?? false}
onCheckedChange={(checked) =>
toggleMutation.mutate({ featureId: feature.id, enabled: checked })
}
/>
</td>
</tr>
))}
</tbody>
</table>
);
}Performance Optimizations
1. Code Splitting
import { lazy, Suspense } from 'react';
// Lazy load admin routes
const SuperadminDashboard = lazy(() =>
import('./features/admin/superadmin/pages/SuperadminDashboard')
);
// Wrap in Suspense
<Suspense fallback={<Loader />}>
<SuperadminDashboard />
</Suspense>2. Memoization
import { useMemo, useCallback } from 'react';
function ExpensiveComponent({ data, onUpdate }) {
// Memoize expensive computation
const processedData = useMemo(() => {
return data.map(item => expensiveTransform(item));
}, [data]);
// Memoize callback
const handleUpdate = useCallback((id) => {
onUpdate(id);
}, [onUpdate]);
return <div>...</div>;
}3. Virtual Scrolling
For large lists (e.g., 1000+ residents):
import { useVirtualizer } from '@tanstack/react-virtual';
function LargeList({ items }) {
const parentRef = useRef();
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Row height
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ItemRow item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}4. Debouncing
import { useDebounce } from '@/hooks/useDebounce';
function SearchBox() {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 500);
const { data } = useQuery({
queryKey: ['search', debouncedSearch],
queryFn: () => searchAPI(debouncedSearch),
enabled: debouncedSearch.length > 2,
});
return <Input value={search} onChange={(e) => setSearch(e.target.value)} />;
}Build Configuration
Vite Configuration
vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { keycloakify } from 'keycloakify/vite-plugin';
export default defineConfig({
plugins: [
react(),
keycloakify({
accountThemeImplementation: 'none',
themeName: 'noumaris',
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'query-vendor': ['@tanstack/react-query'],
'ui-vendor': [
'@radix-ui/react-dialog',
'@radix-ui/react-dropdown-menu',
'@radix-ui/react-select',
],
},
},
},
},
});Environment Variables
Development (.env.development):
VITE_API_URL=https://api-dev.noumaris.com
VITE_KEYCLOAK_URL=https://auth-dev.noumaris.com
VITE_KEYCLOAK_REALM=noumaris
VITE_KEYCLOAK_CLIENT_ID=fastapi-frontendLocalhost (.env.localhost):
VITE_API_URL=http://localhost:8000
VITE_KEYCLOAK_URL=http://localhost:8081
VITE_KEYCLOAK_REALM=noumaris
VITE_KEYCLOAK_CLIENT_ID=fastapi-frontendProduction (.env.production):
VITE_API_URL=https://api.noumaris.com
VITE_KEYCLOAK_URL=https://auth.noumaris.com
VITE_KEYCLOAK_REALM=noumaris
VITE_KEYCLOAK_CLIENT_ID=fastapi-frontendBuild Scripts
{
"scripts": {
"dev": "vite --mode development",
"localhost": "vite --mode localhost",
"build": "vite build",
"build:localhost": "vite build --mode localhost",
"build:dev": "vite build --mode development",
"build:staging": "vite build --mode staging",
"build:prod": "vite build --mode production",
"build-keycloak-theme": "npm run build:prod && KEYCLOAKIFY_THEME_VERSION=prod npx keycloakify",
"preview": "vite preview"
}
}Build output:
dist/
├── assets/
│ ├── index-[hash].js # Main bundle
│ ├── react-vendor-[hash].js # React vendors
│ ├── ui-vendor-[hash].js # UI vendors
│ └── index-[hash].css # Styles
├── index.html
└── favicon.icoSummary
The Noumaris frontend is a modern React SPA optimized for:
- Developer Experience: Vite HMR, TypeScript, ESLint
- User Experience: Fast loads, responsive design, accessibility
- Security: Keycloak OAuth2, JWT tokens, role-based access
- Maintainability: Component patterns, type safety, documentation
- Performance: Code splitting, React Query caching, virtual scrolling
Key Architectural Decisions:
- React 18 + Vite over Next.js (see ADR-006)
- React Query for server state (eliminates Redux)
- Radix UI for accessible components
- Keycloakify for custom auth theme (see ADR-002)
- Feature-based architecture for admin modules
Related Documentation: