Skip to content

devherik/react-typescript-example

Repository files navigation

SaaS Frontend Client

A production-grade React SPA for an AI-driven writing platform. Built to showcase enterprise frontend engineering: secure token-based auth, layered architecture, runtime type safety, and a modular state management system.

Portfolio project. Every decision here was made deliberately — this document explains the why behind the architecture, not just the what.


Tech Stack

Concern Technology
Framework React 19
Language TypeScript 5.9
Build Vite 8
Styling Tailwind CSS 4
State Zustand 5
Routing React Router 7
Validation Zod 4
HTTP Axios
Auth MSAL (Azure AD / @azure/msal-browser)

Architecture

The project enforces Clean Architecture with a strict inward dependency rule: outer layers depend on inner abstractions, never the reverse.

src/
├── presentation/   # UI Layer — pages only; no business logic
├── stores/         # Application Layer — state machines & use cases
├── server/         # Infrastructure Layer — API clients, auth services
├── schemas/        # Shared contracts — Zod schemas + inferred types
├── components/     # Shared UI primitives & layout
└── hooks/          # Thin abstractions over store selectors
Presentation → Application → Infrastructure
                    ↑
               (via interfaces / Zod contracts)

Presentation components never import from server/. The stores mediate all I/O.


Skills Demonstrated

1. TypeScript — Type Safety at the Boundary

Runtime validation with Zod ensures that the static types actually match what arrives from the API. Types are inferred from schemas — no manual DTO duplication.

// Schema is the source of truth
export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
  is_email_verified: z.boolean(),
  auth_provider_id: z.string(),
});

// Type is derived, not declared separately
export type User = z.infer<typeof UserSchema>;

Axios is also type-augmented for custom request metadata:

declare module "axios" {
  interface InternalAxiosRequestConfig {
    metadata?: { startTime: Date };
  }
}

And return shapes are validated with satisfies to catch mismatches at compile time:

return { theme, isDark, setTheme, toggleTheme } satisfies {
  theme: AppTheme;
  isDark: boolean;
  // ...
};

2. State Management — Modular Zustand Stores (SRP)

Each store owns exactly one domain. This makes stores independently testable and prevents the "God Store" anti-pattern.

Store Responsibility
useAuthStore Auth lifecycle: login, logout, hydration, token refresh
useUserStore Profile mutations: update, password change, delete
useAppStore App preferences: theme, locale, OS media query listener
useToastStore Global notification queue and auto-dismiss logic

Hydration pattern — On mount, the app silently validates the existing cookie session. A critical detail: hydration intentionally avoids toggling isLoading to prevent a double-render flash that would briefly unmount the protected route tree:

hydrate: async () => {
  // Note: isLoading is NOT set here.
  // Setting it would cause ProtectedRoute to briefly unmount
  // and re-mount, creating a visible flash for returning users.
  try {
    await AuthServer.getInstance().refreshToken();
    const user = await AuthServer.getInstance().fetchUser();
    set({ user, isAuthenticated: true, isHydrated: true });
  } catch {
    set({ isHydrated: true }); // Unblock the route guard even on failure
  }
}

Race condition guard — Concurrent logouts are prevented with a simple flag:

logout: async () => {
  if (get().isLoggingOut) return; // Guard against double-click
  set({ isLoggingOut: true });
  // ...
}

3. Authentication — MSAL + HTTP-only Cookies

Authentication uses Microsoft MSAL for Azure AD identity, with session continuity managed via HTTP-only cookies (mitigating XSS token theft). The client never handles raw tokens in JavaScript memory on the happy path.

AuthServer is a Singleton service injected into the store layer:

class AuthServer {
  private static instance: AuthServer;

  static getInstance(): AuthServer {
    if (!AuthServer.instance) AuthServer.instance = new AuthServer();
    return AuthServer.instance;
  }
  // login, logout, refreshToken, fetchUser, updateUser ...
}

4. API Layer — Axios Interceptors for Cross-Cutting Concerns

A single Axios instance in apiClient.ts centralises all cross-cutting concerns, keeping individual service methods clean.

Automatic token refresh loop — On a 401, the interceptor refreshes the token and retries the original request transparently. If the failing endpoint is the refresh endpoint, it calls clearAuthState() to break the retry loop:

if (status === 401) {
  if (originalRequest.url?.includes("/hydrate")) {
    useAuthStore.getState().clearAuthState();
    return Promise.reject(error);
  }
  // Otherwise: refresh → retry
  await useAuthStore.getState().refresh();
  return apiClient(originalRequest);
}

Type-safe error extraction — A type guard narrows the unknown error payload before extracting the message, avoiding unsafe any casts:

const isApiErrorResponse = (data: unknown): data is ApiErrorResponse =>
  typeof data === "object" && data !== null;

5. Routing — Lazy Loading, Layouts & Protected Routes

React Router 7 with nested routes share a persistent AppLayout (header + outlet + footer), so the shell never unmounts during navigation.

Code splitting — The Settings page is lazy-loaded to keep the initial bundle lean:

const SettingsPage = lazy(() => import("./presentation/settings/Page"));

Multi-step route guardProtectedRoute handles three distinct states before rendering children, resolving them in the correct order to avoid redirect flashes:

1. isHydrated === false  →  show Loader (session check in progress)
2. isAuthenticated === false  →  redirect to /login
3. allowedGroups check  →  redirect to /dashboard with toast
4. ✓ Render children

Hooks are always called unconditionally (Rules of Hooks compliance), with side effects confined to useEffect:

// Derived BEFORE any early returns
const hasAccess = !allowedGroups?.length || userGroups.some(g => allowedGroups.includes(g));

// Side effect only — never triggers early return directly
useEffect(() => {
  if (isHydrated && isAuthenticated && !hasAccess) {
    showToast("Access denied.", "error");
  }
}, [isHydrated, isAuthenticated, hasAccess]);

6. Component Design — Composition & CSS Modules

UI components follow a composition-first approach. The button system demonstrates vertical layering:

BaseButton      ← Primary action
GhostButton     ← Low-emphasis secondary
IconButton      ← Icon-only with isDelete variant
NavigateButton  ← Navigation with a directional arrow

Modal supports positional variants (top-left, center, bottom-right, etc.), a blur backdrop, click-outside-to-close via a ref, body scroll lock, and a 300ms unmount animation — all composable via props.

Scoped styles use CSS Modules to prevent class name collisions across the component tree.


7. Theme System — OS-Level Preference Detection

The useAppTheme hook abstracts the store selector and resolves the system theme against the OS preference at read time:

const isDark =
  theme === "dark" ||
  (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);

The system variant also registers a matchMedia change listener, so the UI reacts to the user switching their OS theme without a page reload.


Getting Started

Prerequisites

Install

bun install

Develop

bun run dev

The dev server proxies /api/* to http://localhost:8080 via Vite's built-in proxy, keeping CORS concerns out of the application code.

Build

bun run build

All architectural decisions in this project prioritise correctness and maintainability over brevity. The codebase is intentionally over-engineered for a showcase context — in a real product, some abstractions would be introduced incrementally as the need arose.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors