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¶
- What is React?
- React Core Concepts
- Components: The Building Blocks
- Props: Passing Data to Components
- Hooks: Adding Behavior to Components
- Custom Hooks: Reusable Logic
- Context: Global State Management
- React Query: Server State Management
- React Router: Navigation
- Authentication and Permissions
- Project Structure and Conventions
- Styling with Tailwind CSS
- Testing React Components
- Common Patterns in This Codebase
- 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
classNameinstead ofclass(becauseclassis reserved in JS) - Use
htmlForinstead offor - 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¶
- Name must start with uppercase:
OrdersPage, notordersPage - 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...propsspread — 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/meto restore the session on page reload - RBAC — user object includes
rolesandpermissionsarrays - Sentry integration — sets user context for error tracking
- Loading state —
isLoadingprevents 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>
);
}
Navigation Components¶
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)¶
- No class components — use functional components only
- No business logic in JSX — extract to hooks or functions
- No direct API calls in components — use custom hooks with React Query
- Hooks contain logic, components render data
- Max ~300 lines per file — break up larger files
- Explicit types — no
anyorunknown
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¶
- Explore the codebase: Start with
apps/web/src/pages/to see full page implementations - Read the hooks: Check
apps/web/src/hooks/to understand data fetching patterns - Study components: Look at
apps/web/src/components/ui/for reusable component patterns - Understand auth: Read
apps/web/src/contexts/auth-context.tsxand the router - Run the app:
pnpm nx serve weband click around to understand the user flows - Make small changes: Try adding a new filter or modifying a component's styling