frontend-patterns
React 19 and Next.js patterns including component architecture, state management, performance, styling, forms, testing, Chrome extension parity, DX tooling, micro-frontends, UI state machines, and AI chat surface composition.
Frontend Patterns
Production-grade React 19 and Next.js patterns: component architecture (hooks vs server components), state management (React Query, Zustand, signals), performance optimization (memoization, useTransition, streaming SSR, bundle splitting), styling (Tailwind, CSS Modules, design tokens), forms (React Hook Form + Zod), testing (RTL + Vitest + Storybook), Chrome extension parity, DX tooling, micro-frontends, UI state machines, and AI chat surface composition with streaming UX heuristics.
When to Use
- Building or refactoring React/Next.js applications with modern patterns.
- Choosing between server components, client components, hooks, and context boundaries.
- Implementing state management with React Query (server state) or Zustand (UI state).
- Optimizing performance with memoization, transitions, streaming, and code splitting.
- Styling with Tailwind CSS, CSS Modules, design tokens, and responsive grids.
- Building forms with React Hook Form, Zod validation, and accessibility.
- Testing components with React Testing Library, Vitest, and Storybook.
- Sharing component libraries between web apps and WXT Chrome extensions.
- Designing AI chat surfaces with streaming conversations and heuristic-driven UX.
- Setting up DX tooling: ESLint, TypeScript, turborepo, shadcn/ui integration.
Core Concepts
1. Component Architecture
Server Components vs Client Components:
// app/dashboard/page.tsx — Server Component (default)
// Runs on server, zero JS sent to client
import { Suspense } from "react";
import { getDashboardData } from "@/lib/api";
import { RevenueChart } from "@/components/charts/revenue-chart";
import { UserTable } from "@/components/users/user-table";
import { DashboardSkeleton } from "@/components/ui/skeleton";
export default async function DashboardPage() {
// Direct database/API call on the server — no useEffect, no useState
const data = await getDashboardData();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Streaming: chart loads independently */}
<Suspense fallback={<DashboardSkeleton />}>
<RevenueChart initialData={data.revenue} />
</Suspense>
<UserTable users={data.users} />
</div>
);
}
// app/dashboard/revenue-chart.tsx — "use client" directive
// Only this component + its imports go to the client
"use client";
import { LineChart, Line, XAxis, YAxis, Tooltip } from "recharts";
import { useState } from "react";
interface RevenueChartProps {
initialData: Array<{ month: string; revenue: number }>;
}
export function RevenueChart({ initialData }: RevenueChartProps) {
const [hoveredMonth, setHoveredMonth] = useState<string | null>(null);
return (
<div className="rounded-lg border p-4">
<LineChart data={initialData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
formatter={(value: number) => [`$${value.toLocaleString()}`, "Revenue"]}
labelFormatter={(label) => `Month: ${label}`}
/>
<Line
type="monotone"
dataKey="revenue"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}
onMouseEnter={(_, index) => setHoveredMonth(initialData[index]?.month)}
/>
</LineChart>
{hoveredMonth && (
<p className="mt-2 text-sm text-muted-foreground">Viewing: {hoveredMonth}</p>
)}
</div>
);
}
Co-Location Pattern:
components/
├── dashboard/
│ ├── dashboard-page.tsx # Server component entry
│ ├── dashboard-chart.tsx # Client component (co-located)
│ ├── dashboard-stats.tsx
│ ├── dashboard-skeleton.tsx
│ └── index.ts # Barrel export (optional)
├── users/
│ ├── user-table.tsx
│ ├── user-row.tsx
│ ├── user-actions.tsx
│ └── use-user-actions.ts # Hook co-located with consumers
└── ui/ # Shared primitive components
├── button.tsx
├── dialog.tsx
├── input.tsx
└── skeleton.tsx
Context Boundaries — Minimize Re-renders:
// DON'T — one context causes re-render of entire app
const AppContext = createContext<{ user: User; theme: string; lang: string }>(...);
// DO — split contexts by concern
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>("light");
const LocaleContext = createContext<string>("en");
// Even better — use Zustand for frequently-changing UI state
import { create } from "zustand";
interface UIStore {
sidebarOpen: boolean;
toggleSidebar: () => void;
selectedItems: Set<string>;
selectItem: (id: string) => void;
clearSelection: () => void;
}
const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
selectedItems: new Set(),
selectItem: (id) =>
set((s) => {
const next = new Set(s.selectedItems);
next.has(id) ? next.delete(id) : next.add(id);
return { selectedItems: next };
}),
clearSelection: () => set({ selectedItems: new Set() }),
}));
// Select only what you need — no unnecessary re-renders
function SidebarToggle() {
const toggle = useUIStore((s) => s.toggleSidebar); // Only re-renders if toggle changes (never)
return <button onClick={toggle}>Toggle</button>;
}
2. State Management
React Query — Server State:
// lib/query-keys.ts
export const queryKeys = {
users: {
all: ["users"] as const,
lists: () => [...queryKeys.users.all, "list"] as const,
list: (filters: UserFilters) => [...queryKeys.users.lists(), filters] as const,
details: () => [...queryKeys.users.all, "detail"] as const,
detail: (id: string) => [...queryKeys.users.details(), id] as const,
},
} as const;
// hooks/use-users.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { fetchUsers, createUser, type User, type UserFilters } from "@/lib/api";
export function useUsers(filters: UserFilters) {
return useQuery({
queryKey: queryKeys.users.list(filters),
queryFn: () => fetchUsers(filters),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onMutate: async (newUser) => {
// Optimistic update
await queryClient.cancelQueries({ queryKey: queryKeys.users.lists() });
const previous = queryClient.getQueryData(queryKeys.users.lists());
queryClient.setQueryData(queryKeys.users.lists(), (old: User[] | undefined) => [
...(old ?? []),
{ ...newUser, id: crypto.randomUUID(), createdAt: new Date().toISOString() },
]);
return { previous };
},
onError: (_err, _newUser, context) => {
// Rollback on failure
queryClient.setQueryData(queryKeys.users.lists(), context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
},
});
}
Zustand — UI State:
// stores/chat-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
streaming?: boolean;
timestamp: Date;
}
interface ChatStore {
messages: ChatMessage[];
isStreaming: boolean;
currentConversationId: string | null;
addMessage: (message: Omit<ChatMessage, "id" | "timestamp">) => void;
updateStreamingMessage: (id: string, content: string, done: boolean) => void;
clearMessages: () => void;
setConversationId: (id: string) => void;
}
export const useChatStore = create<ChatStore>()(
persist(
(set) => ({
messages: [],
isStreaming: false,
currentConversationId: null,
addMessage: (msg) =>
set((s) => ({
messages: [
...s.messages,
{ ...msg, id: crypto.randomUUID(), timestamp: new Date() },
],
})),
updateStreamingMessage: (id, content, done) =>
set((s) => ({
messages: s.messages.map((m) =>
m.id === id ? { ...m, content, streaming: !done } : m,
),
isStreaming: !done,
})),
clearMessages: () => set({ messages: [], isStreaming: false }),
setConversationId: (id) => set({ currentConversationId: id }),
}),
{
name: "chat-storage",
partialize: (s) => ({
messages: s.messages.slice(-50), // Only persist last 50 messages
currentConversationId: s.currentConversationId,
}),
},
),
);
3. Performance Optimization
Memoization — When and When Not To:
import { memo, useMemo, useCallback, useTransition } from "react";
// GOOD — memo when component re-renders often with same props
const ExpensiveRow = memo(function ExpensiveRow({ data }: { data: RowData }) {
// This component is expensive to render — memo prevents wasted work
return <tr>{/* ... */}</tr>;
});
// BAD — memo on trivial components
const Label = memo(({ text }: { text: string }) => <span>{text}</span>); // Overhead > benefit
// useMemo for expensive computations
const sortedUsers = useMemo(
() => [...users].sort((a, b) => a.name.localeCompare(b.name)),
[users], // Only re-sort when users array changes
);
// useCallback for stable references (passed to memo children or deps arrays)
const handleDelete = useCallback(
(id: string) => {
deleteUser(id);
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
},
[queryClient],
);
// useTransition — keep UI responsive during expensive updates
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (value: string) => {
startTransition(() => {
// Expensive filter — won't block typing
const filtered = heavyFilter(allItems, value);
setResults(filtered);
});
};
return (
<div>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</div>
);
}
Bundle Splitting:
// Dynamic import for route-level code splitting
import dynamic from "next/dynamic";
// Heavy chart component — loaded on demand
const RevenueChart = dynamic(
() => import("@/components/charts/revenue-chart").then((m) => m.RevenueChart),
{
loading: () => <ChartSkeleton />,
ssr: false, // Charts don't need server rendering
},
);
// Admin panel — only loaded when user navigates to /admin
const AdminPanel = dynamic(() => import("@/components/admin/admin-panel"), {
loading: () => <PanelSkeleton />,
});
// Next.js automatic route-level splitting
// app/admin/page.tsx — automatically split from main bundle
Streaming SSR:
// app/layout.tsx
import { Suspense } from "react";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* Shell renders immediately */}
<Header />
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* Suspended until data fetches */}
</Suspense>
<main>{children}</main>
<Suspense fallback={null}>
<Analytics /> {/* Non-blocking — renders when ready */}
</Suspense>
</body>
</html>
);
}
4. Styling — Tailwind, CSS Modules, Design Tokens
Tailwind Best Practices:
// Use Tailwind utility classes with design token aliases
// tailwind.config.ts
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--color-border))",
background: "hsl(var(--color-background))",
foreground: "hsl(var(--color-foreground))",
primary: {
DEFAULT: "hsl(var(--color-primary))",
foreground: "hsl(var(--color-primary-foreground))",
muted: "hsl(var(--color-primary-muted))",
},
destructive: {
DEFAULT: "hsl(var(--color-destructive))",
foreground: "hsl(var(--color-destructive-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/forms")],
} satisfies Config;
// components/ui/card.tsx — Reusable card with Tailwind
import { cn } from "@/lib/utils";
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "elevated" | "outline";
}
export function Card({ className, variant = "default", ...props }: CardProps) {
return (
<div
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
{
"border-border bg-card": variant === "default",
"border-border bg-card shadow-lg": variant === "elevated",
"border-2 border-primary bg-transparent shadow-none": variant === "outline",
},
className,
)}
{...props}
/>
);
}
// Responsive grid with Tailwind
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{items.map((item) => (
<Card key={item.id}>
<ItemCard item={item} />
</Card>
))}
</div>
CSS Modules for Component-Specific Styles:
// components/hero/hero.module.css
.hero {
position: relative;
min-height: 60vh;
display: grid;
place-items: center;
overflow: hidden;
}
.hero::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--color-primary) 0%, transparent 60%);
opacity: 0.1;
}
.title {
font-size: clamp(2rem, 5vw, 4rem);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.1;
}
// components/hero/hero.tsx
import styles from "./hero.module.css";
export function Hero({ title, subtitle }: HeroProps) {
return (
<section className={styles.hero}>
<div className="text-center">
<h1 className={styles.title}>{title}</h1>
<p className="mt-4 text-lg text-muted-foreground">{subtitle}</p>
</div>
</section>
);
}
5. Forms — React Hook Form + Zod
// schemas/user-schema.ts
import { z } from "zod";
export const userFormSchema = z.object({
email: z.string().email("Invalid email address").min(1).max(255),
name: z.string().min(1, "Name is required").max(150),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128)
.regex(/[A-Z]/, "Password must contain an uppercase letter")
.regex(/\d/, "Password must contain a number"),
role: z.enum(["user", "admin", "viewer"]).default("user"),
notifications: z.boolean().default(true),
});
export type UserFormValues = z.infer<typeof userFormSchema>;
// components/users/user-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { userFormSchema, type UserFormValues } from "@/schemas/user-schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useCreateUser } from "@/hooks/use-users";
export function UserForm() {
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: {
email: "",
name: "",
password: "",
role: "user",
notifications: true,
},
mode: "onChange", // Validate on every change (progressive enhancement)
});
const mutation = useCreateUser();
const onSubmit = async (values: UserFormValues) => {
await mutation.mutateAsync(values);
form.reset();
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="user @example.com"
autoComplete="email"
aria-describedby="email-error"
{...field}
/>
</FormControl>
<FormMessage id="email-error" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Jane Doe" autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={mutation.isPending || !form.formState.isValid}>
{mutation.isPending ? "Creating..." : "Create User"}
</Button>
{mutation.error && (
<p role="alert" className="text-sm text-destructive">
{mutation.error.message}
</p>
)}
</form>
</Form>
);
}
6. Testing — RTL + Vitest + Storybook
React Testing Library + Vitest:
// components/users/__tests__/user-table.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { UserTable } from "../user-table";
const server = setupServer(
http.get("/api/v1/users", () =>
HttpResponse.json({
success: true,
data: {
items: [
{ id: "1", email: "a @x.com", name: "Alice", isActive: true },
{ id: "2", email: "b @x.com", name: "Bob", isActive: false },
],
meta: { total: 2, page: 1, pageSize: 20, pages: 1 },
},
}),
),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
describe("UserTable", () => {
it("renders user list from API", async () => {
renderWithProviders(<UserTable />);
// Loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Data loaded
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
expect(screen.getByText("a @x.com")).toBeInTheDocument();
});
it("shows deactivation confirmation", async () => {
const user = userEvent.setup();
renderWithProviders(<UserTable />);
await waitFor(() => expect(screen.getByText("Bob")).toBeInTheDocument());
const deactivateBtn = screen.getByRole("button", { name: /deactivate bob/i });
await user.click(deactivateBtn);
expect(screen.getByText(/confirm deactivation/i)).toBeInTheDocument();
});
it("handles empty state gracefully", async () => {
server.use(
http.get("/api/v1/users", () =>
HttpResponse.json({ success: true, data: { items: [], meta: { total: 0 } } }),
),
);
renderWithProviders(<UserTable />);
await waitFor(() => {
expect(screen.getByText(/no users found/i)).toBeInTheDocument();
});
});
});
Storybook — Story-Driven Development:
// components/ui/button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";
const meta: Meta<typeof Button> = {
title: "UI/Button",
component: Button,
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
},
size: {
control: "select",
options: ["default", "sm", "lg", "icon"],
},
},
args: {
children: "Button",
disabled: false,
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {};
export const Destructive: Story = {
args: { variant: "destructive", children: "Delete" },
};
export const Loading: Story = {
args: { disabled: true, children: "Loading..." },
};
export const WithIcon: Story = {
args: {
children: (
<>
<PlusIcon className="h-4 w-4" />
Add User
</>
),
},
};
7. Chrome Extension Parity — Shared Component Libraries
packages/
├── ui/ # Shared component library
│ ├── src/
│ │ ├── components/
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── input.tsx
│ │ │ └── toast.tsx
│ │ ├── hooks/
│ │ │ ├── use-toast.ts
│ │ │ └── use-media-query.ts
│ │ ├── styles/
│ │ │ ├── globals.css # Tailwind + design tokens
│ │ │ └── tokens.css # CSS custom properties
│ │ └── index.ts # Public API
│ ├── package.json
│ └── tsconfig.json
│
apps/
├── web/ # Next.js web app
│ ├── app/
│ └── components/ # Web-specific wrappers
│ └── uses @ai-dev-kit/ui
│
└── extension/ # WXT Chrome extension
├── entrypoints/
│ ├── popup/ # Uses @ai-dev-kit/ui components
│ ├── options/
│ └── content/
└── components/ # Extension-specific UI
└── uses @ai-dev-kit/ui
// packages/ui/package.json
{
"name": "@ai-dev-kit/ui",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./styles.css": "./dist/styles.css"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
8. DX — ESLint, TypeScript, Turborepo, shadcn/ui
ESLint Config (React 19):
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import nextPlugin from "@next/eslint-plugin-next";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
react,
"react-hooks": reactHooks,
"@next/next": nextPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
"@next/next/no-html-link-for-pages": "error",
"react/react-in-jsx-scope": "off", // React 17+ JSX transform
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
},
},
{
ignores: ["node_modules", ".next", "dist", "build"],
},
);
Turborepo Cache Configuration:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"cache": true
},
"lint": {
"cache": true
},
"type-check": {
"cache": true
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
}
}
}
shadcn/ui Integration:
# Initialize shadcn/ui in monorepo
npx shadcn@latest init
# Add components (goes to packages/ui or apps/web/components/ui)
npx shadcn@latest add button dialog input form toast
# Configure for shared package
# components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "packages/ui/src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@ai-dev-kit/ui/components",
"utils": "@ai-dev-kit/ui/lib/utils"
}
}
9. Architecture Patterns
Micro-Frontends vs Modules:
// Module Federation (micro-frontend) — use when:
// - Independent teams deploy independently
// - Different release cycles per feature area
// - Cross-app component sharing is needed
// Module-based monorepo (turborepo) — use when:
// - Single team or closely coordinated teams
// - Shared design system + utilities
// - Consistent release cycle
// RECOMMENDED for most teams — simpler, less operational overhead
UI State Machines:
// use-request-state.ts — Finite state machine for async operations
type RequestState<D> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: D }
| { status: "error"; error: Error };
function useRequestState<D>(): [RequestState<D>, (action: Action<D>) => void] {
const [state, dispatch] = useReducer(requestReducer, { status: "idle" });
const request = useCallback(async (fn: () => Promise<D>) => {
dispatch({ type: "START" });
try {
const data = await fn();
dispatch({ type: "SUCCESS", data });
} catch (error) {
dispatch({ type: "ERROR", error: error as Error });
}
}, []);
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
return [state, { request, reset }];
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const [state, actions] = useRequestState<User>();
useEffect(() => {
actions.request(() => fetchUser(userId));
}, [userId, actions.request]);
switch (state.status) {
case "idle":
case "loading":
return <UserSkeleton />;
case "error":
return <ErrorBanner message={state.error.message} onRetry={actions.request} />;
case "success":
return <UserCard user={state.data} />;
}
}
AI Chat Surface Composition:
// components/chat/chat-surface.tsx
"use client";
import { useRef, useEffect } from "react";
import { useChatStore } from "@/stores/chat-store";
import { ChatMessage } from "./chat-message";
import { ChatInput } from "./chat-input";
import { StreamingIndicator } from "./streaming-indicator";
import { useStreamingCompletion } from "./use-streaming-completion";
export function ChatSurface() {
const { messages, isStreaming } = useChatStore();
const bottomRef = useRef<HTMLDivElement>(null);
const { completeStream } = useStreamingCompletion();
// Auto-scroll on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length, messages[messages.length - 1]?.content]);
return (
<div className="flex h-full flex-col">
{/* Message list */}
<div className="flex-1 overflow-y-auto px-4 py-6" role="log" aria-live="polite">
{messages.length === 0 ? (
<EmptyState />
) : (
messages.map((msg) => <ChatMessage key={msg.id} message={msg} />)
)}
{isStreaming && <StreamingIndicator />}
<div ref={bottomRef} />
</div>
{/* Input — always visible */}
<div className="border-t bg-background p-4">
<ChatInput
onSubmit={completeStream}
disabled={isStreaming}
/>
</div>
</div>
);
}
UX Heuristics for Streaming Conversations:
// 1. Stream tokens as they arrive — no waiting for full response
// 2. Show typing indicator within 300ms if no tokens received
// 3. Provide cancel/stop button during streaming
// 4. Preserve scroll position at bottom during streaming
// 5. Show partial content with syntax highlighting as it arrives
// 6. Distinguish streaming from complete (e.g., cursor blink)
// 7. Handle errors gracefully — show retry option
// 8. Rate-limit DOM updates — batch token renders at 60fps
// components/chat/use-streaming-completion.ts
export function useStreamingCompletion() {
const { addMessage, updateStreamingMessage } = useChatStore();
const abortControllerRef = useRef<AbortController | null>(null);
const completeStream = useCallback(async (prompt: string) => {
// Add user message immediately
addMessage({ role: "user", content: prompt });
// Create placeholder for assistant response
const assistantId = crypto.randomUUID();
addMessage({ role: "assistant", content: "", streaming: true });
abortControllerRef.current = new AbortController();
try {
const response = await fetch("/api/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, stream: true }),
signal: abortControllerRef.current.signal,
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader!.read();
if (done) break;
const chunk = decoder.decode(value);
// Parse SSE stream
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (data.content) {
accumulated += data.content;
updateStreamingMessage(assistantId, accumulated, false);
}
}
}
}
// Mark complete
updateStreamingMessage(assistantId, accumulated, true);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
// User cancelled — leave partial content
updateStreamingMessage(assistantId, "", true);
} else {
// Error state
updateStreamingMessage(assistantId, "Error generating response.", true);
}
}
}, [addMessage, updateStreamingMessage]);
const cancelStream = useCallback(() => {
abortControllerRef.current?.abort();
}, []);
return { completeStream, cancelStream };
}
Anti-Patterns
- Overusing
useContextfor frequently-changing state — causes full-tree re-renders; use Zustand selectors. - Passing props deeper than 3 levels — compose components or use context/Zustand.
useEffectfor data fetching in client components — use React Query or server components instead.memo()on every component — profiling shows most components don't need it; adds overhead.- Inline object/array props —
{...{a: 1}}creates new reference every render, breaksmemo. - Not using
"use client"boundary explicitly — Next.js App Router defaults to server; mark client components at the leaf. - Tailwind class soup in large components — extract to sub-components or use
cn()utility. - Uncontrolled forms in complex UIs — React Hook Form + Zod gives validation, types, and accessibility.
- Testing implementation details — test what the user sees and does, not internal state.
- Duplicating UI between web and extension — extract shared components to a monorepo package.
- Not setting
gcTime(cacheTime) on React Query — causes memory leaks with unlimited cache growth. - Missing
aria-liveon streaming regions — screen readers won't announce new content. - Blocking main thread with heavy computations — use Web Workers or
useTransition.
Best Practices
- Server components by default — only add
"use client"when you need interactivity. - React Query for server state — caching, deduping, optimistic updates, background refetch.
- Zustand for UI state — lightweight, selector-based, no provider needed.
- Co-locate files with consumers — keep components, hooks, tests, and stories together.
- Tailwind with CSS variables — design tokens for theming, dark mode, and consistency.
cn()utility for conditional classes —clsx+tailwind-mergefor conflict resolution.- React Hook Form + Zod — type-safe validation with progressive enhancement.
- RTL queries by role/text —
getByRole,getByText, notgetByTestIdunless necessary. - MSW for API mocking — intercept at network layer, works with RTL and E2E.
- Storybook for component catalog — every UI primitive gets a story with variants.
- Dynamic imports for heavy components — charts, editors, maps behind
Suspense. - useTransition for expensive renders — keep input responsive during list filtering.
- Stream SSE responses for AI chat — token-by-token rendering with abort support.
aria-live="polite"on streaming regions — accessible screen reader announcements.- Turborepo for monorepos — cached builds, parallel dev, consistent tooling.
- shadcn/ui as component baseline — copy (not install) for full customization control.
- State machines for async flows — explicit
idle → loading → success/erroreliminates edge cases. - Scroll anchoring for chat —
scrollIntoView({ behavior: "smooth" })on new messages. - Cancel streaming on unmount — abort controller in
useEffectcleanup. - Design tokens as CSS custom properties — single source of truth for colors, spacing, radii.
Related Skills
backend-patterns— FastAPI architecture, API design, error contractspython-testing— Pytest patterns, coverage, AI regression testingwxt-chrome-extension— WXT framework, Manifest V3, extension-specific UIfrontend-design— Design quality, visual hierarchy, modern minimal UItdd-workflow— Test-driven development, RED-GREEN-REFACTORe2e-testing— Playwright end-to-end patternsapi-design— API contracts, versioning, OpenAPI schemascoding-standards— Code style, linting, formatting conventions