Frontend Architecture
The frontend is a modern React application built with TypeScript, Vite, and a comprehensive set of libraries for UI, state management, and data fetching.
Technology Stack
- React 19 - UI library with latest features
- TypeScript - Type-safe JavaScript
- Vite - Fast build tool and dev server
- TanStack Query - Server state management
- Redux Toolkit - Client state management
- React Router 7 - Routing
- Tailwind CSS 4 - Utility-first styling
- Radix UI - Accessible component primitives
- Tiptap - Rich text editor
- Vitest - Unit testing
Project Structure
apps/client-frontend/
├── src/
│ ├── components/ # React components
│ │ ├── ui/ # Reusable UI components (buttons, inputs, etc.)
│ │ ├── course/ # Course-related components
│ │ ├── quiz/ # Quiz components
│ │ ├── discussion/ # Discussion components
│ │ ├── calendar/ # Calendar components
│ │ └── ... # Other domain-specific components
│ │
│ ├── pages/ # Page-level components
│ │ ├── Dashboard.tsx
│ │ ├── CoursePage.tsx
│ │ ├── QuizPage.tsx
│ │ └── ...
│ │
│ ├── hooks/ # Custom React hooks
│ │ ├── useAuth.ts
│ │ ├── useDebounce.ts
│ │ └── ...
│ │
│ ├── queries/ # TanStack Query hooks
│ │ ├── courses.ts
│ │ ├── quizzes.ts
│ │ ├── members.ts
│ │ └── index.ts
│ │
│ ├── redux/ # Redux store setup
│ │ ├── store.ts
│ │ ├── slices/ # Redux slices
│ │ └── ...
│ │
│ ├── routes/ # Route definitions
│ │ └── router.tsx
│ │
│ ├── utilities/ # Utility functions
│ │ └── ...
│ │
│ ├── lib/ # Library configurations
│ │ └── utils.ts
│ │
│ ├── bootstrap/ # Initialization code
│ │
│ ├── App.tsx # Root component
│ ├── main.tsx # Application entry point
│ └── index.css # Global styles
│
├── vite.config.ts # Vite configuration
├── vitest.config.ts # Vitest configuration
├── tailwind.config.js # Tailwind configuration
├── tsconfig.json # TypeScript configuration
└── package.jsonCore Concepts
Component Architecture
Components are organized by domain and follow a consistent pattern:
typescript
// Example: CourseCard.tsx
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import type { Course } from '@repo/api';
interface CourseCardProps {
course: Course;
onSelect: (id: number) => void;
}
export function CourseCard({ course, onSelect }: CourseCardProps) {
return (
<Card>
<CardHeader>
<h3>{course.name}</h3>
<p>{course.code}</p>
</CardHeader>
<CardContent>
<p>{course.description}</p>
<Button onClick={() => onSelect(course.id)}>
View Course
</Button>
</CardContent>
</Card>
);
}Best Practices:
- Use TypeScript interfaces for props
- Import types from
@repo/api - Keep components focused and single-responsibility
- Extract reusable logic into hooks
- Use composition over inheritance
State Management
The application uses a dual state management approach:
Server State (TanStack Query)
For data from the API:
typescript
// queries/courses.ts
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@repo/api";
import { queries } from "./index";
export function useCourses() {
return useQuery({
queryKey: queries.courses.all.queryKey,
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/courses");
if (error) throw error;
return data;
},
});
}
export function useCreateCourse() {
return useMutation({
mutationFn: async (courseData) => {
const { data, error } = await apiClient.POST("/api/courses", {
body: courseData,
});
if (error) throw error;
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queries.courses.all.queryKey });
},
});
}Query Key Factory Pattern:
typescript
// queries/index.ts
import { createQueryKeyStore } from "@lukemorales/query-key-factory";
export const queries = createQueryKeyStore({
courses: {
all: null,
detail: (id: number) => [id],
modules: (courseId: number) => ["modules", courseId],
},
quizzes: {
list: (courseId: number) => [courseId],
detail: (id: number) => [id],
attempts: (quizId: number) => ["attempts", quizId],
},
});Client State (Redux)
For UI state and user preferences:
typescript
// redux/slices/authSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
}
const authSlice = createSlice({
name: "auth",
initialState: {
user: null,
token: null,
isAuthenticated: false,
} as AuthState,
reducers: {
setCredentials: (
state,
action: PayloadAction<{ user: User; token: string }>,
) => {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
},
logout: (state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
},
},
});
export const { setCredentials, logout } = authSlice.actions;
export default authSlice.reducer;Usage in Components:
typescript
import { useSelector, useDispatch } from 'react-redux';
import { setCredentials, logout } from '@/redux/slices/authSlice';
import type { RootState } from '@/redux/store';
export function UserProfile() {
const dispatch = useDispatch();
const { user, isAuthenticated } = useSelector((state: RootState) => state.auth);
const handleLogout = () => {
dispatch(logout());
};
if (!isAuthenticated) return <Navigate to="/login" />;
return (
<div>
<h2>Welcome, {user?.name}</h2>
<button onClick={handleLogout}>Logout</button>
</div>
);
}Routing
React Router 7 manages navigation:
typescript
// routes/router.tsx
import { createBrowserRouter, RouterProvider } from 'react-router';
import { Dashboard } from '@/pages/Dashboard';
import { CoursePage } from '@/pages/CoursePage';
import { ProtectedRoute } from '@/components/ProtectedRoute';
const router = createBrowserRouter([
{
path: '/',
element: <ProtectedRoute><Dashboard /></ProtectedRoute>,
},
{
path: '/courses/:courseId',
element: <ProtectedRoute><CoursePage /></ProtectedRoute>,
},
{
path: '/courses/:courseId/quizzes/:quizId',
element: <ProtectedRoute><QuizPage /></ProtectedRoute>,
},
{
path: '/login',
element: <LoginPage />,
},
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}Route Parameters:
typescript
import { useParams } from 'react-router';
export function CoursePage() {
const { courseId } = useParams<{ courseId: string }>();
const courseIdNum = Number(courseId);
const { data: course } = useCourse(courseIdNum);
return <div>{course?.name}</div>;
}Custom Hooks
Extract reusable logic into hooks:
typescript
// hooks/useAuth.ts
import { useSelector } from "react-redux";
import { useNavigate } from "react-router";
import type { RootState } from "@/redux/store";
export function useAuth() {
const { user, token, isAuthenticated } = useSelector(
(state: RootState) => state.auth,
);
const navigate = useNavigate();
const requireAuth = () => {
if (!isAuthenticated) {
navigate("/login");
return false;
}
return true;
};
return {
user,
token,
isAuthenticated,
requireAuth,
};
}typescript
// hooks/useDebounce.ts
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}UI Components
Component Library
Built on Radix UI primitives with custom styling:
typescript
// components/ui/button.tsx
import { forwardRef } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
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',
outline: 'border border-input hover:bg-accent',
ghost: 'hover:bg-accent',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = 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}
/>
);
}
);
export { Button, buttonVariants };Rich Text Editor
Tiptap integration for content editing:
typescript
// components/RichTextEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Bold, Italic, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface RichTextEditorProps {
content: string;
onChange: (content: string) => void;
}
export function RichTextEditor({ content, onChange }: RichTextEditorProps) {
const editor = useEditor({
extensions: [StarterKit],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
if (!editor) return null;
return (
<div className="border rounded-md">
<div className="border-b p-2 flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'bg-accent' : ''}
>
<Bold className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'bg-accent' : ''}
>
<Italic className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'bg-accent' : ''}
>
<List className="h-4 w-4" />
</Button>
</div>
<EditorContent editor={editor} className="p-4" />
</div>
);
}Data Fetching Patterns
Query Invalidation
typescript
export function useDeleteCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const { error } = await apiClient.DELETE("/api/courses/{id}", {
params: { path: { id } },
});
if (error) throw error;
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: queries.courses.all.queryKey });
},
});
}Optimistic Updates
typescript
export function useUpdateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }) => {
const { data: updated, error } = await apiClient.PUT(
"/api/courses/{id}",
{
params: { path: { id } },
body: data,
},
);
if (error) throw error;
return updated;
},
onMutate: async ({ id, data }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: queries.courses.detail(id).queryKey,
});
// Snapshot previous value
const previous = queryClient.getQueryData(
queries.courses.detail(id).queryKey,
);
// Optimistically update
queryClient.setQueryData(queries.courses.detail(id).queryKey, (old) => ({
...old,
...data,
}));
return { previous };
},
onError: (err, { id }, context) => {
// Rollback on error
queryClient.setQueryData(
queries.courses.detail(id).queryKey,
context?.previous,
);
},
onSettled: (data, error, { id }) => {
// Refetch after mutation
queryClient.invalidateQueries({
queryKey: queries.courses.detail(id).queryKey,
});
},
});
}Prefetching
typescript
export function CourseList() {
const queryClient = useQueryClient();
const { data: courses } = useCourses();
const prefetchCourse = (id: number) => {
queryClient.prefetchQuery({
queryKey: queries.courses.detail(id).queryKey,
queryFn: async () => {
const { data } = await apiClient.GET('/api/courses/{id}', {
params: { path: { id } },
});
return data;
},
});
};
return (
<div>
{courses?.map(course => (
<div
key={course.id}
onMouseEnter={() => prefetchCourse(course.id)}
>
{course.name}
</div>
))}
</div>
);
}Form Handling
Forms with validation and error handling:
typescript
import { useState } from 'react';
import { useCreateCourse } from '@/queries/courses';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
export function CreateCourseForm() {
const [formData, setFormData] = useState({
name: '',
code: '',
description: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const createCourse = useCreateCourse();
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.name) newErrors.name = 'Name is required';
if (!formData.code) newErrors.code = 'Code is required';
return newErrors;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
try {
await createCourse.mutateAsync(formData);
// Success - reset form
setFormData({ name: '', code: '', description: '' });
setErrors({});
} catch (error) {
console.error('Failed to create course:', error);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
placeholder="Course Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
{errors.name && <p className="text-red-500 text-sm">{errors.name}</p>}
</div>
<div>
<Input
placeholder="Course Code"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
/>
{errors.code && <p className="text-red-500 text-sm">{errors.code}</p>}
</div>
<div>
<Textarea
placeholder="Description (optional)"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<Button type="submit" disabled={createCourse.isPending}>
{createCourse.isPending ? 'Creating...' : 'Create Course'}
</Button>
</form>
);
}Performance Optimization
Code Splitting
typescript
import { lazy, Suspense } from 'react';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
const QuizPage = lazy(() => import('@/pages/QuizPage'));
export function Routes() {
return (
<Suspense fallback={<LoadingSpinner />}>
<QuizPage />
</Suspense>
);
}Memoization
typescript
import { memo, useMemo } from 'react';
interface CourseListProps {
courses: Course[];
onSelect: (id: number) => void;
}
export const CourseList = memo(({ courses, onSelect }: CourseListProps) => {
const sortedCourses = useMemo(
() => [...courses].sort((a, b) => a.name.localeCompare(b.name)),
[courses]
);
return (
<div>
{sortedCourses.map(course => (
<CourseCard key={course.id} course={course} onSelect={onSelect} />
))}
</div>
);
});Testing
See Testing Guide for comprehensive testing strategies.
Build and Deployment
bash
# Development
pnpm dev
# Build for production
pnpm build
# Preview production build
pnpm preview
# Run tests
pnpm test
# Coverage
pnpm coverageBest Practices
- Type Safety: Always use TypeScript, avoid
any - Component Size: Keep components small and focused
- Hooks: Extract logic into custom hooks
- Query Keys: Use query key factory pattern
- Error Handling: Handle loading and error states
- Accessibility: Use Radix UI components
- Performance: Lazy load heavy components
- Testing: Write tests for critical paths
Related Documentation
- Type Safety Flow - Type generation process
- API Package - API client usage
- Testing - Frontend testing strategies