Skip to content

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 ​

  1. Overview
  2. Technology Stack
  3. Project Structure
  4. Component Architecture
  5. State Management
  6. Routing & Navigation
  7. Authentication Flow
  8. Data Fetching Patterns
  9. Styling Conventions
  10. Forms & Validation
  11. Admin Dashboard Architecture
  12. Performance Optimizations
  13. Build Configuration

Overview ​

The Noumaris frontend is a single-page application (SPA) built with React 18 and Vite. It serves two primary user groups:

  1. Clinical Users (Residents) - Clinical documentation and scribe features
  2. 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.css

The 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 ​

mermaid
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

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

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

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

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:

ts
// 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:

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

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
tsx
// 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:

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:

jsx
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('general');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);

Routing & Navigation ​

React Router v7 Configuration ​

App.jsx:

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:

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:

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:

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 valid
  • unauthenticated: No session, redirect to login

3. Login Flow ​

mermaid
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: Response

4. Token Management ​

Token Refresh:

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

js
fetch('/api/sessions', {
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${user.token}`
  }
})

Data Fetching Patterns ​

Pattern 1: Simple Query ​

jsx
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 ​

jsx
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 ​

jsx
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 ​

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

jsx
// 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:

tsx
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) ​

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() ​

ts
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Usage:

tsx
<div className={cn(
  "base-class",
  isActive && "active-class",
  className // Allow prop override
)} />

Forms & Validation ​

React Hook Form + Zod ​

Example: Create Institution Form

jsx
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 ​

  1. Superadmin - System-wide management

    • Create/edit/delete institutions
    • Manage institution admins
    • System analytics
    • Feature access matrix
  2. 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:

jsx
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 ​

jsx
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 ​

jsx
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):

jsx
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 ​

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

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

env
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-frontend

Localhost (.env.localhost):

env
VITE_API_URL=http://localhost:8000
VITE_KEYCLOAK_URL=http://localhost:8080
VITE_KEYCLOAK_REALM=noumaris
VITE_KEYCLOAK_CLIENT_ID=fastapi-frontend

Production (.env.production):

env
VITE_API_URL=https://api.noumaris.com
VITE_KEYCLOAK_URL=https://auth.noumaris.com
VITE_KEYCLOAK_REALM=noumaris
VITE_KEYCLOAK_CLIENT_ID=fastapi-frontend

Build Scripts ​

json
{
  "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.ico

Summary ​

The Noumaris frontend is a modern React SPA optimized for:

  1. Developer Experience: Vite HMR, TypeScript, ESLint
  2. User Experience: Fast loads, responsive design, accessibility
  3. Security: Keycloak OAuth2, JWT tokens, role-based access
  4. Maintainability: Component patterns, type safety, documentation
  5. 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:

Internal documentation for Noumaris platform