Skip to content

React Crash Course for Forma3D Connect

Target Audience: Junior developers with TypeScript knowledge and basic SPA concepts
Goal: Get up to speed to independently work on this project's React frontend


Table of Contents

  1. What is React?
  2. React Core Concepts
  3. Components: The Building Blocks
  4. Props: Passing Data to Components
  5. Hooks: Adding Behavior to Components
  6. Custom Hooks: Reusable Logic
  7. Context: Global State Management
  8. React Query: Server State Management
  9. React Router: Navigation
  10. Authentication and Permissions
  11. Project Structure and Conventions
  12. Styling with Tailwind CSS
  13. Testing React Components
  14. Common Patterns in This Codebase
  15. Quick Reference

1. What is React?

React is a JavaScript library for building user interfaces. Unlike traditional web development where you manipulate the DOM directly, React uses a declarative approach:

  • You describe what the UI should look like based on the current state
  • React figures out how to update the DOM efficiently

Key Mental Model

Think of React as a function:

UI = f(state)

Your UI is the result of applying your component functions to the current state. When state changes, React re-runs the relevant functions and updates only what changed in the browser.


2. React Core Concepts

JSX: JavaScript + HTML

JSX lets you write HTML-like syntax in JavaScript:

// This is JSX - it looks like HTML but it's JavaScript
const element = <h1 className="title">Hello, World!</h1>;

// It compiles to this:
const element = React.createElement('h1', { className: 'title' }, 'Hello, World!');

Key differences from HTML:

  • Use className instead of class (because class is reserved in JS)
  • Use htmlFor instead of for
  • All attributes are camelCase: onClick, onChange, tabIndex
  • Self-closing tags must have a slash: <img />, <input />

Embedding JavaScript in JSX

Use curly braces {} to embed any JavaScript expression:

const name = 'Forma3D';
const count = 5;

return (
  <div>
    <h1>Welcome to {name}</h1>
    <p>You have {count} orders</p>
    <p>That's {count > 0 ? 'great!' : 'zero orders'}</p>
  </div>
);

Rendering Lists

Use .map() to render arrays. Each item needs a unique key prop:

const orders = [
  { id: 'abc', name: 'Order A' },
  { id: 'def', name: 'Order B' },
];

return (
  <ul>
    {orders.map((order) => (
      <li key={order.id}>{order.name}</li>
    ))}
  </ul>
);

Note: Always use a unique ID as the key, not the array index. The index is only acceptable when the list never reorders.

Conditional Rendering

Several patterns for showing/hiding content:

// 1. Ternary operator
return isLoading ? <Spinner /> : <Content />;

// 2. Logical AND (renders if truthy)
return hasError && <ErrorMessage />;

// 3. Early return
if (isLoading) {
  return <Spinner />;
}
return <Content />;

3. Components: The Building Blocks

Components are reusable pieces of UI. In this project, we exclusively use functional components.

Basic Component

function Greeting() {
  return <h1>Hello!</h1>;
}

export function OrdersPage() {
  return (
    <div>
      <Greeting />
      <p>Your orders will appear here.</p>
    </div>
  );
}

Component Rules

  1. Name must start with uppercase: OrdersPage, not ordersPage
  2. Must return a single root element (use fragments if needed):
// Wrong - multiple root elements
function Wrong() {
  return (
    <h1>Title</h1>
    <p>Content</p>
  );
}

// Correct - wrapped in a fragment
function Correct() {
  return (
    <>
      <h1>Title</h1>
      <p>Content</p>
    </>
  );
}

Real Example from Our Codebase

From apps/web/src/components/layout/root-layout.tsx:

import { Outlet } from 'react-router-dom';
import { Header } from './header';
import { Sidebar } from './sidebar';

export function RootLayout() {
  return (
    <div className="min-h-screen bg-theme-primary text-theme-primary">
      <Header />
      <Sidebar />
      <main className="ml-64 pt-16 min-h-screen p-6">
        <Outlet /> {/* Child routes render here */}
      </main>
    </div>
  );
}

4. Props: Passing Data to Components

Props are how you pass data from parent to child components. Think of them as function arguments.

Basic Props

interface GreetingProps {
  name: string;
  age?: number; // Optional prop (?)
}

function Greeting({ name, age }: GreetingProps) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      {age && <p>You are {age} years old</p>}
    </div>
  );
}

// Usage
<Greeting name="John" />
<Greeting name="Jane" age={25} />

The children Prop

A special prop that contains nested elements:

interface CardProps {
  children: React.ReactNode;
  title?: string;
}

function Card({ children, title }: CardProps) {
  return (
    <div className="rounded-xl border border-theme-secondary bg-theme-card p-6">
      {title && <h2>{title}</h2>}
      {children}
    </div>
  );
}

// Usage - anything between the tags becomes `children`
<Card title="Orders">
  <p>Your orders appear here</p>
  <button>View All</button>
</Card>

Real Example: Button Component

From apps/web/src/components/ui/button.tsx:

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = 'primary', size = 'md', loading, disabled, children, ...props }, ref) => {
    const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg...';

    const variants = {
      primary: 'bg-accent-primary text-white hover:bg-accent-primary-hover...',
      secondary: 'bg-theme-secondary text-white hover:bg-theme-tertiary...',
      // ...
    };

    return (
      <button
        ref={ref}
        className={clsx(baseStyles, variants[variant], sizes[size], className)}
        disabled={disabled || loading}
        {...props}
      >
        {loading && <Spinner />}
        {children}
      </button>
    );
  }
);

Key patterns:

  • extends ButtonHTMLAttributes<HTMLButtonElement> — inherit all native button props
  • Default values: variant = 'primary'
  • forwardRef — allows parent to get a reference to the button element
  • ...props spread — forward any extra props to the native element

5. Hooks: Adding Behavior to Components

Hooks are functions that let you "hook into" React features. They must:

  • Start with use (convention)
  • Be called at the top level of components (not inside loops, conditions, or nested functions)
  • Be called from React functions (components or custom hooks)

useState: Component State

Stores values that, when changed, cause the component to re-render:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount((prev) => prev + 1)}>Increment (safer)</button>
    </div>
  );
}

Best Practice: Use the function form setCount(prev => prev + 1) when the new value depends on the previous value.

Real Example: Filter State

From apps/web/src/pages/orders/index.tsx:

export function OrdersPage() {
  const [page, setPage] = useState(1);
  const [status, setStatus] = useState<OrderStatus | ''>('');
  const [search, setSearch] = useState('');

  const { data, isLoading } = useOrders({ page, pageSize: 25, status, search });

  return (
    <div>
      <Select
        value={status}
        onChange={(e) => {
          setStatus(e.target.value as OrderStatus | '');
          setPage(1); // Reset to page 1 when filter changes
        }}
      />
      {/* ... */}
    </div>
  );
}

useEffect: Side Effects

Runs code after render. Used for things like subscriptions or DOM manipulation:

import { useEffect, useState } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);

    // Cleanup function - runs when component unmounts
    return () => clearInterval(interval);
  }, []); // Empty dependency array = run once on mount

  return <p>Elapsed: {seconds}s</p>;
}

Dependency Array Patterns:

useEffect(() => {
  // Runs after EVERY render
});

useEffect(() => {
  // Runs ONCE after initial render
}, []);

useEffect(() => {
  // Runs when `userId` or `page` changes
}, [userId, page]);

Real Example: Socket Connection

From apps/web/src/contexts/socket-context.tsx:

useEffect(() => {
  const socketInstance = io(`${SOCKET_URL}/events`, {
    transports: ['websocket'],
    autoConnect: true,
  });

  socketInstance.on('connect', () => setIsConnected(true));
  socketInstance.on('disconnect', () => setIsConnected(false));

  socketInstance.on('order:created', (data) => {
    toast.success(`New order received: #${data.orderNumber}`);
    queryClient.invalidateQueries({ queryKey: ['orders'] });
  });

  setSocket(socketInstance);

  return () => {
    socketInstance.disconnect();
  };
}, [queryClient]);

useCallback: Memoized Functions

Prevents creating new function instances on every render:

import { useCallback } from 'react';

function Auth() {
  const [user, setUser] = useState<User | null>(null);

  // With useCallback: same function instance unless dependencies change
  const login = useCallback(async (email: string, password: string) => {
    const response = await fetch('/api/v1/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ email, password }),
    });
    const userData = await response.json();
    setUser(userData);
  }, []);

  return <LoginForm onLogin={login} />;
}

When to use: When passing functions to child components or using them in dependency arrays.


6. Custom Hooks: Reusable Logic

Custom hooks extract and reuse stateful logic across components. They're just functions that use other hooks.

Real Example: useOrders Hook

From apps/web/src/hooks/use-orders.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/api-client';
import type { OrderStatus, ShipmentStatus } from '@forma3d/domain';

interface OrdersQuery {
  page?: number;
  pageSize?: number;
  status?: OrderStatus | '';
  hasFailedJobs?: boolean;
  hasActivePrintJobs?: boolean;
  shipmentStatus?: ShipmentStatus | '';
  readyToShip?: boolean;
  search?: string;
}

export function useOrders(query: OrdersQuery = {}) {
  return useQuery({
    queryKey: ['orders', query],
    queryFn: () => apiClient.orders.list(query),
  });
}

export function useOrder(id: string) {
  return useQuery({
    queryKey: ['orders', id],
    queryFn: () => apiClient.orders.get(id),
    enabled: !!id, // Only fetch if id exists
  });
}

export function useCancelOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (orderId: string) => apiClient.orders.cancel(orderId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });
}

export function useRetryPrintJob() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (jobId: string) => apiClient.printJobs.retry(jobId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
      queryClient.invalidateQueries({ queryKey: ['print-jobs'] });
    },
  });
}

Usage in a component:

function OrdersPage() {
  const { data, isLoading, error } = useOrders({ page: 1 });
  const cancelOrder = useCancelOrder();

  if (isLoading) return <LoadingPage />;
  if (error) return <ErrorPage message={error.message} />;

  return (
    <div>
      {data.orders.map((order) => (
        <OrderRow
          key={order.id}
          order={order}
          onCancel={() => cancelOrder.mutate(order.id)}
        />
      ))}
    </div>
  );
}

Available Custom Hooks

This project has many domain-specific hooks:

Hook Purpose
useOrders, useOrder Order list and detail queries
useCancelOrder, useRetryPrintJob Order/print job mutations
useMappings Product mapping queries
useShipments Shipment queries
useDashboard Dashboard statistics
useAnalytics Analytics data (charts)
useLogs Event log queries
useAuditLogs Audit log queries
useInventory Inventory management
useFeatureFlags Feature flag queries
useHealth Backend health check
useOnlineStatus Browser online/offline detection
usePwaInstall PWA install prompt
usePushNotifications Push notification subscription

7. Context: Global State Management

Context provides a way to pass data through the component tree without prop drilling.

When to Use Context

  • Authentication state
  • Theme settings
  • User preferences
  • Any data needed by many components at different levels

Creating a Context

From apps/web/src/contexts/auth-context.tsx:

import { createContext, useContext, useState, useCallback, useEffect, useMemo, type ReactNode } from 'react';
import * as Sentry from '@sentry/react';

export interface User {
  id: string;
  tenantId: string;
  email: string;
  roles: string[];
  permissions: string[];
}

interface AuthContextType {
  isAuthenticated: boolean;
  isLoading: boolean;
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  hasPermission: (permission: string) => boolean;
  hasRole: (role: string) => boolean;
  hasAnyPermission: (permissions: string[]) => boolean;
  hasAllPermissions: (permissions: string[]) => boolean;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // Check for existing session on mount
  useEffect(() => {
    const checkSession = async () => {
      try {
        const response = await fetch('/api/v1/auth/me', {
          credentials: 'include',
        });
        if (response.ok) {
          const userData = await response.json();
          setUser(userData);
          Sentry.setUser({ id: userData.id, email: userData.email });
          Sentry.setTag('tenantId', userData.tenantId);
        }
      } catch {
        setUser(null);
        Sentry.setUser(null);
      } finally {
        setIsLoading(false);
      }
    };
    checkSession();
  }, []);

  const login = useCallback(async (email: string, password: string) => {
    const response = await fetch('/api/v1/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new Error(error.message || 'Invalid credentials');
    }

    const userData = await response.json();
    setUser(userData);
    Sentry.setUser({ id: userData.id, email: userData.email });
    Sentry.setTag('tenantId', userData.tenantId);
  }, []);

  const logout = useCallback(async () => {
    try {
      await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' });
    } finally {
      setUser(null);
      Sentry.setUser(null);
    }
  }, []);

  const hasPermission = useCallback(
    (permission: string) => !!user?.permissions.includes(permission),
    [user]
  );

  const hasRole = useCallback(
    (role: string) => !!user?.roles.includes(role),
    [user]
  );

  // ... hasAnyPermission, hasAllPermissions ...

  const value = useMemo<AuthContextType>(
    () => ({
      isAuthenticated: !!user,
      isLoading,
      user,
      login,
      logout,
      hasPermission,
      hasRole,
      hasAnyPermission,
      hasAllPermissions,
    }),
    [user, isLoading, login, logout, hasPermission, hasRole, hasAnyPermission, hasAllPermissions]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

export function usePermissions() {
  const { user, hasPermission, hasAnyPermission, hasAllPermissions, hasRole } = useAuth();
  return {
    permissions: user?.permissions ?? [],
    roles: user?.roles ?? [],
    hasPermission,
    hasRole,
    hasAnyPermission,
    hasAllPermissions,
  };
}

Key differences from a typical auth context:

  • Session-based — uses credentials: 'include' to send cookies, no tokens in localStorage
  • Session check on mount — calls /api/v1/auth/me to restore the session on page reload
  • RBAC — user object includes roles and permissions arrays
  • Sentry integration — sets user context for error tracking
  • Loading stateisLoading prevents flash of login page while checking session

Using the Context

// Wrap your app with providers (in app.tsx)
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <SocketProvider>
          <RouterProvider router={router} />
        </SocketProvider>
      </AuthProvider>
    </QueryClientProvider>
  );
}

// Use the hook anywhere in the tree
function Navbar() {
  const { isAuthenticated, user, logout } = useAuth();

  return (
    <nav>
      {isAuthenticated && (
        <>
          <span>Welcome, {user?.email}</span>
          <button onClick={logout}>Logout</button>
        </>
      )}
    </nav>
  );
}

8. React Query: Server State Management

React Query (TanStack Query) handles all server state: fetching, caching, synchronization, and updates.

Why React Query?

  • Automatic caching and background refetching
  • Loading and error states built-in
  • Automatic retries
  • Pagination and infinite scroll support
  • Optimistic updates
  • Devtools for debugging

Setup

From apps/web/src/app.tsx:

import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/query-client';

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        {/* Your app */}
      </AuthProvider>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Fetching Data with useQuery

import { useQuery } from '@tanstack/react-query';

function OrdersPage() {
  const {
    data,      // The fetched data
    isLoading, // True during initial load
    isError,   // True if query failed
    error,     // The error object
    isFetching, // True during any fetch (including background)
    refetch,   // Function to manually refetch
  } = useQuery({
    queryKey: ['orders', { page: 1, status: 'PENDING' }],
    queryFn: () => apiClient.orders.list({ page: 1 }),
    staleTime: 5000,
    refetchOnWindowFocus: true,
  });

  if (isLoading) return <Spinner />;
  if (isError) return <Error message={error.message} />;

  return <OrderList orders={data.orders} />;
}

Mutations with useMutation

For creating, updating, or deleting data:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function CancelOrderButton({ orderId }: { orderId: string }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (id: string) => apiClient.orders.cancel(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
      toast.success('Order cancelled');
    },
    onError: (error) => {
      toast.error(`Failed: ${error.message}`);
    },
  });

  return (
    <Button
      onClick={() => mutation.mutate(orderId)}
      loading={mutation.isPending}
      disabled={mutation.isPending}
    >
      Cancel Order
    </Button>
  );
}

Query Keys

Query keys are used for caching, invalidation, and deduplication:

// Simple key
queryKey: ['orders']

// Key with parameters - different params = different cache
queryKey: ['orders', { page: 1, status: 'PENDING' }]

// Nested key for related data
queryKey: ['orders', orderId, 'print-jobs']

9. React Router: Navigation

React Router handles client-side navigation in SPAs.

Route Configuration

From apps/web/src/router.tsx:

import { createBrowserRouter, Navigate } from 'react-router-dom';

export const router = createBrowserRouter([
  {
    path: '/login',
    element: <LoginPage />,
  },
  {
    path: '/',
    element: (
      <ProtectedRoute>
        <RootLayout />
      </ProtectedRoute>
    ),
    children: [
      { index: true, element: withSuspense(DashboardPage) },
      { path: 'orders', element: withSuspense(OrdersPage) },
      { path: 'orders/:id', element: withSuspense(OrderDetailPage) },
      { path: 'mappings', element: withSuspense(MappingsPage) },
      { path: 'mappings/new', element: withSuspense(NewMappingPage) },
      { path: 'logs', element: withSuspense(LogsPage) },
      { path: 'settings', element: withSuspense(SettingsPage) },
      { path: 'settings/integrations', element: withSuspense(IntegrationsPage) },
      // Permission-gated routes
      {
        path: 'inventory',
        element: (
          <PermissionGatedRoute requiredPermission="inventory.read">
            {withSuspense(InventoryPage)}
          </PermissionGatedRoute>
        ),
      },
      {
        path: 'admin/users',
        element: (
          <PermissionGatedRoute requiredPermission="users.read">
            {withSuspense(UsersPage)}
          </PermissionGatedRoute>
        ),
      },
      {
        path: 'admin/audit-logs',
        element: (
          <PermissionGatedRoute requiredPermission="audit.read">
            {withSuspense(AuditLogsPage)}
          </PermissionGatedRoute>
        ),
      },
    ],
  },
  { path: '*', element: <NotFoundPage /> },
]);

Code Splitting with Lazy Loading

Pages are lazy-loaded for smaller initial bundle size:

const DashboardPage = lazy(() =>
  import('./pages/dashboard').then((m) => ({ default: m.DashboardPage }))
);

function withSuspense(Component: React.LazyExoticComponent<React.ComponentType>) {
  return (
    <Suspense fallback={<PageLoadingFallback />}>
      <Component />
    </Suspense>
  );
}
import { Link, useNavigate } from 'react-router-dom';

function Navigation() {
  const navigate = useNavigate();

  return (
    <nav>
      {/* Declarative navigation */}
      <Link to="/orders">Orders</Link>
      <Link to={`/orders/${orderId}`}>View Order</Link>

      {/* Programmatic navigation */}
      <button onClick={() => navigate('/orders')}>Go to Orders</button>
      <button onClick={() => navigate(-1)}>Go Back</button>
    </nav>
  );
}

Reading URL Parameters

import { useParams, useSearchParams } from 'react-router-dom';

// For route: /orders/:id
function OrderDetailPage() {
  const { id } = useParams<{ id: string }>();
  const { data } = useOrder(id!);
  return <OrderDetail order={data} />;
}

// For query strings: /orders?page=2&status=PENDING
function OrdersPage() {
  const [searchParams, setSearchParams] = useSearchParams();

  const page = Number(searchParams.get('page')) || 1;
  const status = searchParams.get('status') || '';

  const goToPage = (newPage: number) => {
    setSearchParams({ page: String(newPage), status });
  };
}

Outlet for Nested Routes

The Outlet component renders child routes:

function RootLayout() {
  return (
    <div>
      <Header />
      <Sidebar />
      <main>
        <Outlet /> {/* Child route components render here */}
      </main>
    </div>
  );
}

10. Authentication and Permissions

Session-Based Auth

This project uses session-based authentication, not tokens or API keys. The browser stores a session cookie (forma3d.sid) that is sent automatically with every request.

// Login - sends credentials, receives session cookie
const login = async (email: string, password: string) => {
  const response = await fetch('/api/v1/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include', // Required for cookies
    body: JSON.stringify({ email, password }),
  });
  if (!response.ok) throw new Error('Invalid credentials');
  return response.json();
};

// Logout - destroys the session server-side
const logout = async () => {
  await fetch('/api/v1/auth/logout', {
    method: 'POST',
    credentials: 'include',
  });
};

// Session check - restore session on page reload
const checkSession = async () => {
  const response = await fetch('/api/v1/auth/me', {
    credentials: 'include',
  });
  if (response.ok) return response.json();
  return null;
};

Protected Routes

function ProtectedRoute({ children }: Readonly<{ children: ReactNode }>) {
  const { isAuthenticated, isLoading } = useAuth();
  const location = useLocation();

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

Permission-Gated Routes

Some routes require specific permissions. Users without the required permission are redirected:

function PermissionGatedRoute({
  children,
  requiredPermission,
  fallbackPath = '/',
}: Readonly<{
  children: ReactNode;
  requiredPermission: string;
  fallbackPath?: string;
}>) {
  const { hasPermission } = usePermissions();

  if (!hasPermission(requiredPermission)) {
    return <Navigate to={fallbackPath} replace />;
  }

  return children;
}

Permission-Based UI

Hide or disable UI elements based on user permissions:

function OrderActions({ order }: { order: Order }) {
  const { hasPermission } = usePermissions();

  return (
    <div>
      {/* Always visible */}
      <Button onClick={() => navigate(`/orders/${order.id}`)}>View</Button>

      {/* Only visible to users with orders.write permission */}
      {hasPermission('orders.write') && (
        <Button variant="danger" onClick={() => cancelOrder.mutate(order.id)}>
          Cancel
        </Button>
      )}

      {/* Only admins see admin actions */}
      {hasPermission('admin.operations') && (
        <Button variant="ghost" onClick={() => forceStatus(order.id)}>
          Force Status
        </Button>
      )}
    </div>
  );
}

Common Permissions

Permission Grants access to
orders.read View orders
orders.write Cancel, reactivate orders
mappings.read View product mappings
mappings.write Create/edit mappings
inventory.read View inventory
inventory.write Adjust stock, configure
users.read View user list
users.write Create/edit users
audit.read View audit logs
admin.operations Developer tools, feature flags

11. Project Structure and Conventions

Directory Structure

apps/web/src/
├── app.tsx                    # Root component with providers
├── router.tsx                 # Route configuration (lazy loading)
├── main.tsx                   # Entry point
├── styles.css                 # Global styles (Tailwind)
├── components/                # Reusable components
│   ├── layout/                # Header, Sidebar, RootLayout, MobileNav
│   ├── ui/                    # Generic UI (Button, Card, Badge, Modal, Pagination, etc.)
│   ├── orders/                # Order-specific components (ShippingInfo)
│   ├── mappings/              # Mapping modals (SimplyPrint file picker, part library)
│   ├── users/                 # User management modals
│   ├── inventory/             # Stock adjustment modal
│   ├── analytics/             # Chart components (order trend, revenue, print jobs)
│   ├── charts/                # Generic chart wrappers (line, bar, donut)
│   ├── logs/                  # Log detail modal
│   └── logo.tsx               # App logo component
├── contexts/                  # React contexts
│   ├── auth-context.tsx       # Session-based auth with RBAC
│   └── socket-context.tsx     # WebSocket connection (Socket.IO)
├── hooks/                     # Custom hooks (22 hooks)
│   ├── use-orders.ts          # Order CRUD operations
│   ├── use-mappings.ts        # Product mappings
│   ├── use-shipments.ts       # Shipment queries
│   ├── use-inventory.ts       # Inventory management
│   ├── use-dashboard.ts       # Dashboard statistics
│   ├── use-analytics.ts       # Analytics data
│   ├── use-logs.ts            # Event logs
│   ├── use-audit-logs.ts      # Audit logs
│   ├── use-feature-flags.ts   # Feature flag queries
│   ├── use-health.ts          # Backend health check
│   ├── use-online-status.ts   # Browser online/offline
│   ├── use-pwa-install.ts     # PWA install prompt
│   └── ...
├── lib/                       # Utilities and configuration
│   ├── api-client.ts          # API wrapper (all endpoints)
│   ├── query-client.ts        # React Query config
│   └── constants.ts           # App constants
├── pages/                     # Page components (route handlers)
│   ├── dashboard.tsx          # /
│   ├── login.tsx              # /login
│   ├── not-found.tsx          # 404
│   ├── orders/
│   │   ├── index.tsx          # /orders
│   │   └── [id].tsx           # /orders/:id
│   ├── mappings/
│   │   ├── index.tsx          # /mappings
│   │   └── new.tsx            # /mappings/new and /mappings/:id/edit
│   ├── logs/
│   │   └── index.tsx          # /logs
│   ├── settings/
│   │   ├── index.tsx          # /settings (tiles dashboard)
│   │   ├── integrations.tsx   # SimplyPrint + Sendcloud
│   │   ├── grid-config-tile.tsx
│   │   ├── stock-management-tile.tsx
│   │   ├── simplyprint-files.tsx
│   │   ├── simplyprint-integration.tsx
│   │   ├── sendcloud-integration.tsx
│   │   ├── feature-flags.tsx
│   │   └── developer-tools.tsx
│   ├── inventory/
│   │   ├── index.tsx          # /inventory
│   │   ├── config.tsx         # /inventory/config
│   │   ├── transactions.tsx   # /inventory/transactions
│   │   └── replenishment.tsx  # /inventory/replenishment
│   └── admin/
│       ├── users.tsx          # /admin/users
│       └── audit-logs/
│           └── index.tsx      # /admin/audit-logs
└── test/                      # Test utilities
    ├── setup.ts
    ├── test-utils.tsx
    └── mocks/

Naming Conventions

Type Convention Example
Components PascalCase OrdersPage, Button
Files kebab-case use-orders.ts, auth-context.tsx
Hooks camelCase, starts with use useOrders, useAuth
Constants SCREAMING_SNAKE_CASE ORDER_STATUS_COLORS
Interfaces PascalCase OrdersQuery, ButtonProps

Project Rules (from .cursorrules)

  1. No class components — use functional components only
  2. No business logic in JSX — extract to hooks or functions
  3. No direct API calls in components — use custom hooks with React Query
  4. Hooks contain logic, components render data
  5. Max ~300 lines per file — break up larger files
  6. Explicit types — no any or unknown

12. Styling with Tailwind CSS

This project uses Tailwind CSS v4 for styling. Instead of writing CSS files, you apply utility classes directly in JSX.

Basic Usage

function Card() {
  return (
    <div className="rounded-xl border border-theme-secondary bg-theme-card p-6">
      <h2 className="text-lg font-semibold text-theme-primary">Title</h2>
      <p className="mt-2 text-sm text-theme-secondary">Description</p>
    </div>
  );
}

Common Patterns

// Spacing
<div className="p-4">Padding all sides</div>
<div className="px-4 py-2">Padding horizontal/vertical</div>
<div className="mt-4 mb-2">Margin top/bottom</div>
<div className="space-y-4">Gap between children (vertical)</div>

// Flexbox
<div className="flex items-center justify-between gap-4">
  <span>Left</span>
  <span>Right</span>
</div>

// Grid
<div className="grid grid-cols-3 gap-4">
  <div>1</div>
  <div>2</div>
  <div>3</div>
</div>

// Responsive
<div className="p-2 md:p-4 lg:p-6">
  {/* p-2 on mobile, p-4 on medium screens, p-6 on large */}
</div>

// Hover/Focus states
<button className="bg-accent-primary hover:bg-accent-primary-hover focus:ring-2">
  Click me
</button>

// Conditional classes with clsx
import { clsx } from 'clsx';

<div className={clsx(
  'base-classes',
  isActive && 'active-classes',
  variant === 'primary' && 'primary-classes'
)}>

Theme Colors

The app uses CSS custom properties for theming:

bg-theme-primary    - Main background
bg-theme-card       - Card backgrounds
border-theme-secondary - Borders, dividers
text-theme-primary  - Primary text
text-theme-secondary - Secondary text
bg-accent-primary   - Primary accent (interactive)
bg-accent-primary-hover - Accent hover state

13. Testing React Components

We use Vitest and React Testing Library for testing.

Basic Component Test

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button } from './button';

describe('Button', () => {
  it('renders children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('applies variant classes', () => {
    render(<Button variant="danger">Delete</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-red-600');
  });
});

Testing Hooks

import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useOrders } from './use-orders';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthProvider>{children}</AuthProvider>
      </QueryClientProvider>
    );
  };
}

describe('useOrders', () => {
  it('fetches orders', async () => {
    const { result } = renderHook(() => useOrders(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data?.orders).toHaveLength(2);
  });
});

User Interactions

import userEvent from '@testing-library/user-event';

it('calls onClick when clicked', async () => {
  const handleClick = vi.fn();
  const user = userEvent.setup();

  render(<Button onClick={handleClick}>Click me</Button>);

  await user.click(screen.getByRole('button'));

  expect(handleClick).toHaveBeenCalledTimes(1);
});

14. Common Patterns in This Codebase

Pattern 1: Page Component Structure

export function OrdersPage() {
  // 1. State
  const [page, setPage] = useState(1);
  const [status, setStatus] = useState<OrderStatus | ''>('');

  // 2. Data fetching
  const { data, isLoading, error } = useOrders({ page, status });

  // 3. Permissions
  const { hasPermission } = usePermissions();

  // 4. Error handling
  if (error) {
    return <Card className="bg-red-500/10">Error: {error.message}</Card>;
  }

  // 5. Render
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold text-theme-primary">Orders</h1>
      </div>

      <Card padding="sm">
        <Select value={status} onChange={(e) => setStatus(e.target.value)} />
      </Card>

      <Card padding="none">
        {isLoading ? (
          <LoadingPage />
        ) : !data?.orders.length ? (
          <EmptyState title="No orders found" />
        ) : (
          <>
            <OrderTable orders={data.orders} />
            <Pagination page={page} total={data.total} onPageChange={setPage} />
          </>
        )}
      </Card>

      {hasPermission('admin.operations') && (
        <Card title="Admin Actions">
          {/* Admin-only section */}
        </Card>
      )}
    </div>
  );
}

Pattern 2: API Client Organization

// lib/api-client.ts
export const apiClient = {
  orders: {
    list: (params) => request('/api/v1/orders', { params }),
    get: (id) => request(`/api/v1/orders/${id}`),
    cancel: (id) => request(`/api/v1/orders/${id}/cancel`, { method: 'PUT' }),
    reactivate: (id) => request(`/api/v1/orders/${id}/reactivate`, { method: 'PUT' }),
    updateStatus: (id, status) => request(`/api/v1/orders/${id}/status`, { method: 'PUT', body: { status } }),
  },
  printJobs: {
    getByOrderId: (orderId) => request(`/api/v1/orders/${orderId}/print-jobs`),
    retry: (id) => request(`/api/v1/print-jobs/${id}/retry`, { method: 'POST' }),
    cancel: (id, reason) => request(`/api/v1/print-jobs/${id}/cancel`, { method: 'PUT', body: { reason } }),
    forceStatus: (id, status, reason) => request(`/api/v1/print-jobs/${id}/force-status`, { method: 'PUT', body: { status, reason } }),
  },
  mappings: { /* ... */ },
  inventory: { /* ... */ },
  admin: { /* users, audit logs */ },
};

Pattern 3: Query + Mutation Hook Pair

// Query hook (for fetching)
export function useOrders(query: OrdersQuery = {}) {
  return useQuery({
    queryKey: ['orders', query],
    queryFn: () => apiClient.orders.list(query),
  });
}

// Mutation hook (for modifying)
export function useCancelOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (orderId: string) => apiClient.orders.cancel(orderId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });
}

Pattern 4: Reusable UI Components

interface BadgeProps {
  variant: 'success' | 'warning' | 'error' | 'default';
  children: ReactNode;
}

export function Badge({ variant, children }: BadgeProps) {
  const variants = {
    success: 'bg-green-500/10 text-green-400',
    warning: 'bg-yellow-500/10 text-yellow-400',
    error: 'bg-red-500/10 text-red-400',
    default: 'bg-slate-500/10 text-slate-400',
  };

  return (
    <span className={clsx('px-2 py-1 rounded-full text-xs font-medium', variants[variant])}>
      {children}
    </span>
  );
}

15. Quick Reference

Essential Imports

// React core
import { useState, useEffect, useCallback, useContext, createContext, lazy, Suspense } from 'react';
import type { ReactNode } from 'react';

// React Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// React Router
import { Link, useNavigate, useParams, useSearchParams, Navigate } from 'react-router-dom';

// Utilities
import { clsx } from 'clsx';
import { format } from 'date-fns';
import toast from 'react-hot-toast';

// Project
import { apiClient } from '../lib/api-client';
import { useAuth, usePermissions } from '../contexts/auth-context';

// Sentry
import * as Sentry from '@sentry/react';

Cheat Sheet

What you want How to do it
Store component state const [value, setValue] = useState(initial)
Fetch data const { data } = useQuery({ queryKey, queryFn })
Modify data const { mutate } = useMutation({ mutationFn })
Navigate programmatically const navigate = useNavigate()
Read URL param const { id } = useParams()
Access auth state const { user, isAuthenticated } = useAuth()
Check permission const { hasPermission } = usePermissions()
Conditional class className={clsx('base', condition && 'extra')}
Show toast toast.success('Message') or toast.error('Error')
Format date format(new Date(date), 'MMM d, yyyy')
Lazy load page const Page = lazy(() => import('./pages/page'))

Running the App

# Start development server
pnpm nx serve web

# Run tests
pnpm nx test web

# Build for production
pnpm nx build web

Next Steps

  1. Explore the codebase: Start with apps/web/src/pages/ to see full page implementations
  2. Read the hooks: Check apps/web/src/hooks/ to understand data fetching patterns
  3. Study components: Look at apps/web/src/components/ui/ for reusable component patterns
  4. Understand auth: Read apps/web/src/contexts/auth-context.tsx and the router
  5. Run the app: pnpm nx serve web and click around to understand the user flows
  6. Make small changes: Try adding a new filter or modifying a component's styling

Resources