Skip to content

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.json

Core 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 coverage

Best Practices

  1. Type Safety: Always use TypeScript, avoid any
  2. Component Size: Keep components small and focused
  3. Hooks: Extract logic into custom hooks
  4. Query Keys: Use query key factory pattern
  5. Error Handling: Handle loading and error states
  6. Accessibility: Use Radix UI components
  7. Performance: Lazy load heavy components
  8. Testing: Write tests for critical paths