Skip to main content
ship before you scale

The Frontend Shell: React, shadcn/ui, TanStack Query, and a Component Structure That Does Not Fight Itself

9 min read Chapter 19 of 42

The Frontend Shell

A frontend that fights its own architecture wastes more developer time than any other technical decision. Components that fetch their own data create waterfall request chains. Global state management for server data creates synchronization bugs. Custom component libraries create maintenance burdens that grow with every new feature. The frontend shell established in this chapter avoids all three by choosing boring, well-tested solutions for each problem.

TanStack Query manages server state. React Hook Form manages form state. shadcn/ui provides components. The application code handles only what these libraries cannot: the business logic of Marketflow’s user interface.

The Feature

An organizer logs in and sees a dashboard with their markets, pending applications, and upcoming market days. They can create markets, define stalls, review applications, and create market days. A vendor logs in and sees their applications and bookings. Both interfaces are responsive, fast, and built from the same component library.

The Decision

shadcn/ui over Material UI, Chakra, or custom components. shadcn/ui is not a component library in the traditional sense. It copies component source code into the project. The components are owned by the developer, not imported from a package that changes on update. This means no breaking changes from library updates, full customization without fighting an API, and components that are exactly the code they appear to be.

TanStack Query over Redux, Zustand, or manual fetch. Server state (data from the API) and client state (which tab is selected, whether a modal is open) are different problems. TanStack Query handles server state with caching, background refetching, optimistic updates, and error handling. Using Redux or Zustand for server state requires manually implementing all of this. Client state is handled by React’s built-in useState and useReducer.

React Router v6 for routing. A SPA with client-side routing. No server-side rendering needed. The API serves JSON. The frontend serves HTML. The browser handles the rest.

The Implementation

Project Setup

npm create vite@latest frontend -- --template react-ts
cd frontend
npm install

# Core dependencies
npm install @tanstack/react-query react-router-dom react-hook-form
npm install @supabase/supabase-js axios

# shadcn/ui setup
npx shadcn-ui@latest init
# Select: TypeScript, Default style, slate base color, CSS variables
npx shadcn-ui@latest add button card input label table dialog \
  select tabs badge separator dropdown-menu sheet sidebar toast

Layout Component

// frontend/src/components/layout/DashboardLayout.tsx
import { Outlet, Link, useLocation } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuButton,
  SidebarProvider,
} from "@/components/ui/sidebar";

const organizerNavItems = [
  { title: "Markets", href: "/dashboard/markets", icon: "🏪" },
  { title: "Applications", href: "/dashboard/applications", icon: "📋" },
  { title: "Market Days", href: "/dashboard/market-days", icon: "📅" },
  { title: "Settings", href: "/dashboard/settings", icon: "⚙️" },
];

export function DashboardLayout() {
  const { user, signOut } = useAuth();
  const location = useLocation();

  return (
    <SidebarProvider>
      <div className="flex h-screen w-full">
        <Sidebar>
          <SidebarContent>
            <SidebarGroup>
              <SidebarGroupLabel>Marketflow</SidebarGroupLabel>
              <SidebarGroupContent>
                <SidebarMenu>
                  {organizerNavItems.map((item) => (
                    <SidebarMenuItem key={item.href}>
                      <SidebarMenuButton
                        asChild
                        isActive={location.pathname.startsWith(item.href)}
                      >
                        <Link to={item.href}>
                          <span>{item.icon}</span>
                          <span>{item.title}</span>
                        </Link>
                      </SidebarMenuButton>
                    </SidebarMenuItem>
                  ))}
                </SidebarMenu>
              </SidebarGroupContent>
            </SidebarGroup>
          </SidebarContent>
          <div className="p-4 border-t">
            <p className="text-sm text-muted-foreground mb-2">{user?.email}</p>
            <Button variant="outline" size="sm" onClick={signOut}>
              Log out
            </Button>
          </div>
        </Sidebar>

        <main className="flex-1 overflow-y-auto p-6">
          <Outlet />
        </main>
      </div>
    </SidebarProvider>
  );
}

Market List Page

// frontend/src/pages/dashboard/MarketsPage.tsx
import { Link } from "react-router-dom";
import { useGetMarkets } from "@/api/generated";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";

export function MarketsPage() {
  const { data: markets, isLoading, error } = useGetMarkets();

  if (isLoading) {
    return (
      <div className="space-y-4">
        {[1, 2, 3].map((i) => (
          <Skeleton key={i} className="h-32 w-full" />
        ))}
      </div>
    );
  }

  if (error) {
    return (
      <div className="text-center py-12">
        <p className="text-destructive">Failed to load markets. Try refreshing.</p>
      </div>
    );
  }

  if (!markets?.length) {
    return (
      <div className="text-center py-12">
        <h2 className="text-lg font-semibold mb-2">No markets yet</h2>
        <p className="text-muted-foreground mb-4">
          Create your first market to start managing vendors.
        </p>
        <Button asChild>
          <Link to="/dashboard/markets/new">Create Market</Link>
        </Button>
      </div>
    );
  }

  return (
    <div>
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">My Markets</h1>
        <Button asChild>
          <Link to="/dashboard/markets/new">Create Market</Link>
        </Button>
      </div>
      <div className="grid gap-4">
        {markets.map((market) => (
          <Link key={market.id} to={`/dashboard/markets/${market.id}`}>
            <Card className="hover:border-primary transition-colors">
              <CardHeader>
                <CardTitle className="flex items-center justify-between">
                  {market.name}
                  <Badge variant={market.is_active ? "default" : "secondary"}>
                    {market.is_active ? "Active" : "Inactive"}
                  </Badge>
                </CardTitle>
              </CardHeader>
              <CardContent>
                <p className="text-muted-foreground">{market.location}</p>
              </CardContent>
            </Card>
          </Link>
        ))}
      </div>
    </div>
  );
}

Every page handles three states: loading, error, and empty. The loading state uses skeletons instead of spinners because skeletons indicate the shape of the incoming content, reducing layout shift when data arrives. The error state provides a clear message. The empty state provides a call to action.

Create Market Form

// frontend/src/pages/dashboard/CreateMarketPage.tsx
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { useCreateMarket } from "@/api/generated";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useToast } from "@/components/ui/use-toast";

interface CreateMarketFormData {
  name: string;
  location: string;
  description: string;
}

export function CreateMarketPage() {
  const navigate = useNavigate();
  const { toast } = useToast();
  const createMarket = useCreateMarket();
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<CreateMarketFormData>();

  const onSubmit = async (data: CreateMarketFormData) => {
    try {
      const market = await createMarket.mutateAsync({ data });
      toast({ title: "Market created", description: `${market.name} is ready.` });
      navigate(`/dashboard/markets/${market.id}`);
    } catch {
      toast({
        title: "Failed to create market",
        description: "Check your input and try again.",
        variant: "destructive",
      });
    }
  };

  return (
    <Card className="max-w-2xl">
      <CardHeader>
        <CardTitle>Create a new market</CardTitle>
      </CardHeader>
      <CardContent>
        <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
          <div>
            <Label htmlFor="name">Market name</Label>
            <Input
              id="name"
              {...register("name", {
                required: "Name is required",
                maxLength: { value: 200, message: "Maximum 200 characters" },
              })}
            />
            {errors.name && (
              <p className="text-sm text-destructive mt-1">{errors.name.message}</p>
            )}
          </div>
          <div>
            <Label htmlFor="location">Location</Label>
            <Input
              id="location"
              {...register("location", {
                required: "Location is required",
                maxLength: { value: 500, message: "Maximum 500 characters" },
              })}
            />
            {errors.location && (
              <p className="text-sm text-destructive mt-1">{errors.location.message}</p>
            )}
          </div>
          <div>
            <Label htmlFor="description">Description</Label>
            <Textarea
              id="description"
              {...register("description", {
                maxLength: { value: 5000, message: "Maximum 5000 characters" },
              })}
            />
            {errors.description && (
              <p className="text-sm text-destructive mt-1">
                {errors.description.message}
              </p>
            )}
          </div>
          <Button type="submit" disabled={isSubmitting}>
            {isSubmitting ? "Creating..." : "Create Market"}
          </Button>
        </form>
      </CardContent>
    </Card>
  );
}

Forms use React Hook Form for validation and state management. Validation rules on the frontend mirror the Pydantic constraints on the backend (same maxLength values). The backend still validates independently. Frontend validation provides instant feedback. Backend validation provides security.

Application Review Page

// frontend/src/pages/dashboard/ApplicationsPage.tsx
import { useParams } from "react-router-dom";
import { useGetMarketApplications, useUpdateApplicationStatus } from "@/api/generated";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/components/ui/use-toast";
import { useCallback } from "react";

export function ApplicationsPage() {
  const { marketId } = useParams<{ marketId: string }>();
  const { data: applications, isLoading } = useGetMarketApplications(marketId!);
  const updateStatus = useUpdateApplicationStatus();
  const queryClient = useQueryClient();
  const { toast } = useToast();

  const handleStatusChange = useCallback(
    async (applicationId: string, status: "accepted" | "rejected") => {
      try {
        await updateStatus.mutateAsync({
          marketId: marketId!,
          applicationId,
          data: { status },
        });
        queryClient.invalidateQueries({
          queryKey: ["markets", marketId, "applications"],
        });
        toast({
          title: `Application ${status}`,
          description: `The vendor has been notified.`,
        });
      } catch {
        toast({
          title: "Action failed",
          description: "Could not update the application. Try again.",
          variant: "destructive",
        });
      }
    },
    [marketId, updateStatus, queryClient, toast]
  );

  if (isLoading) return <div>Loading applications...</div>;

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">Vendor Applications</h1>
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Business Name</TableHead>
            <TableHead>Email</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Applied</TableHead>
            <TableHead>Actions</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {applications?.map((app) => (
            <TableRow key={app.id}>
              <TableCell className="font-medium">
                {app.vendor_business_name}
              </TableCell>
              <TableCell>{app.vendor_email}</TableCell>
              <TableCell>
                <Badge
                  variant={
                    app.status === "accepted"
                      ? "default"
                      : app.status === "rejected"
                        ? "destructive"
                        : "secondary"
                  }
                >
                  {app.status}
                </Badge>
              </TableCell>
              <TableCell>
                {new Date(app.created_at).toLocaleDateString()}
              </TableCell>
              <TableCell>
                {app.status === "pending" && (
                  <div className="flex gap-2">
                    <Button
                      size="sm"
                      onClick={() => handleStatusChange(app.id, "accepted")}
                    >
                      Accept
                    </Button>
                    <Button
                      size="sm"
                      variant="destructive"
                      onClick={() => handleStatusChange(app.id, "rejected")}
                    >
                      Reject
                    </Button>
                  </div>
                )}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

After a status update, queryClient.invalidateQueries refetches the applications list. The table updates to show the new status. No manual state management. No optimistic update logic (the operation is fast enough that the refetch completes before the user notices).

The Trap

// TRAP: Fetching data inside a component that renders in a list
function MarketCard({ marketId }: { marketId: string }) {
  // Each card triggers its own API request
  // 10 markets = 10 parallel requests = waterfall on slow connections
  const { data: stats } = useGetMarketStats(marketId);
  return <Card>{stats?.vendorCount} vendors</Card>;
}

// Parent renders 10 cards = 10 API calls
function MarketsPage() {
  const { data: markets } = useGetMarkets();
  return markets?.map((m) => <MarketCard key={m.id} marketId={m.id} />);
}
// SAFE: Fetch stats as part of the market list API response
// Backend endpoint returns markets with stats included
function MarketsPage() {
  const { data: markets } = useGetMarketsWithStats();
  return markets?.map((m) => (
    <Card key={m.id}>{m.vendor_count} vendors</Card>
  ));
}

Fetching data inside list items creates N+1 request patterns. The market list is one request. Stats for each market are N additional requests. Add a single backend endpoint that returns markets with their stats, and the N+1 problem disappears.

The Cost

DependencySize (gzipped)Cost
React 18~42 KB$0
React Router~14 KB$0
TanStack Query~12 KB$0
React Hook Form~9 KB$0
shadcn/ui (all components)~30 KB$0
Axios~6 KB$0

Total frontend bundle size: approximately 113 KB gzipped, before application code. This loads in under 1 second on a 3G connection. No code splitting is necessary at this scale, though it becomes worthwhile when the application grows past 30 pages.