Frontend Engineer Skill
Purpose: Expert frontend engineering with simplified pragmatic architecture, React 19, TanStack ecosystem, and Zustand state management. Provides implementation examples for building testable, maintainable, scalable frontend applications.
When to Use:
-
Implementing frontend features and components
-
Setting up project structure
-
Creating pages and state management
-
Designing gateway injection patterns
-
Setting up HTTP communication and routing
-
Organizing feature modules
-
Performance optimization and code splitting
NOTE: For UI component design, Tailwind styling, shadcn/ui setup, responsive layouts, and accessibility implementation, defer to the ui-designer skill. This skill focuses on architecture, state management, and business logic orchestration.
Documentation Lookup (MANDATORY)
ALWAYS use MCP servers for up-to-date documentation:
Context7 MCP: Use for comprehensive library documentation, API reference, import statements, and version-specific patterns
-
When user asks about TanStack Router, Query, Form, Table APIs
-
For React 19 features and patterns
-
For Vite configuration and build setup
-
For Zustand API and patterns
-
To verify correct import paths, hooks usage, and API patterns
Perplexity MCP: Use for architectural research, design patterns, and best practices
-
When researching pragmatic frontend architectures
-
For state management strategies and trade-offs
-
For performance optimization techniques
-
For folder structure and code organization patterns
-
For troubleshooting complex architectural issues
Examples of when to use MCP:
-
"How to setup TanStack Router file-based routing?" → Use Context7 MCP for TanStack Router docs
-
"What are React 19 use() hook patterns?" → Use Context7 MCP for React docs
-
"How to use Zustand with TypeScript?" → Use Context7 MCP for Zustand docs
-
"Best practices for feature-based architecture in React?" → Use Perplexity MCP for research
-
"How to configure Vite with React 19?" → Use Context7 MCP for Vite docs
Tech Stack
For complete frontend tech stack details, see "Tech Stack > Frontend" section in CLAUDE.md
Quick Architecture Reference:
This skill focuses on:
-
Feature-based architecture (NOT Clean Architecture layers)
-
Gateway Pattern (Interface + HTTP + Fake)
-
Context API injection for testability
-
Zustand for global client state
-
TanStack Query for server state
Testing:
-
Unit: Vitest + React Testing Library
-
E2E: Playwright
→ See project-standards skill for complete tech stack → See ui-designer skill for UI/UX specific technologies → See docs/plans/2025-10-24-simplified-frontend-architecture-design.md for architecture details
Architecture Overview
Frontend follows a simplified, pragmatic, feature-based architecture.
Core Principles:
-
Feature-based organization - Code grouped by business functionality
-
Pages as use cases - Pages orchestrate business logic
-
Externalized state - Zustand stores are framework-agnostic, 100% testable
-
Gateway injection - Gateways injected via Context API for isolated testing
-
YAGNI rigorously - No unnecessary layers or abstractions
Design Philosophy: Simplicity and pragmatism over architectural purity. Remove Clean Architecture complexity (domain/application/infrastructure layers) in favor of direct, maintainable code.
Recommended Structure
apps/web/src/ ├── app/ # Application setup │ ├── config/ │ │ ├── env.ts # Environment variables │ │ └── index.ts │ ├── providers/ │ │ ├── gateway-provider.tsx # Gateway injection (Context API) │ │ ├── query-provider.tsx # TanStack Query setup │ │ ├── theme-provider.tsx # Theme provider (shadcn/ui) │ │ └── index.ts │ ├── router.tsx # TanStack Router configuration │ └── main.tsx # Application entry point │ ├── features/ # Feature modules (self-contained) │ ├── auth/ │ │ ├── components/ # Pure UI components │ │ │ ├── login-form.tsx │ │ │ └── index.ts │ │ ├── pages/ # Use cases - orchestrate logic │ │ │ ├── login-page.tsx │ │ │ └── index.ts │ │ ├── stores/ # Zustand stores - testable entities │ │ │ ├── auth-store.ts │ │ │ └── index.ts │ │ ├── gateways/ # External resource abstractions │ │ │ ├── auth-gateway.ts # Interface + HTTP implementation │ │ │ ├── auth-gateway.fake.ts # Fake for unit tests │ │ │ └── index.ts │ │ ├── hooks/ # Custom hooks (optional) │ │ │ └── index.ts │ │ ├── types/ # TypeScript types │ │ │ ├── user.ts │ │ │ └── index.ts │ │ └── index.ts # Barrel file - public API │ │ │ ├── dashboard/ │ └── profile/ │ ├── shared/ # Shared code across features │ ├── services/ # Global services │ │ ├── http-api.ts # HTTP client base (Axios wrapper) │ │ ├── storage.ts # LocalStorage/Cookie abstraction │ │ └── index.ts │ ├── components/ │ │ ├── ui/ # shadcn/ui components │ │ └── layout/ # Shared layouts │ ├── hooks/ # Global utility hooks │ ├── lib/ # Utilities and helpers │ │ ├── validators.ts # Zod schemas (common) │ │ ├── formatters.ts │ │ └── index.ts │ └── types/ # Global types │ ├── routes/ # TanStack Router routes │ ├── __root.tsx │ ├── index.tsx │ └── auth/ │ └── login.tsx │ └── index.css
Benefits:
-
✅ Feature isolation - delete folder, remove feature
-
✅ No unnecessary layers - direct, maintainable code
-
✅ Testable business logic - Zustand stores are pure JS/TS
-
✅ Isolated page testing - inject fake gateways
-
✅ Clear separation - pages (orchestration), components (UI), stores (state)
Layer Responsibilities (MANDATORY)
- Shared Services Layer
Purpose: Reusable infrastructure used across features.
Example - HTTP Client:
// shared/services/http-api.ts import axios, { type AxiosInstance, type AxiosRequestConfig } from "axios";
export class HttpApi { private client: AxiosInstance;
constructor(baseURL: string) { this.client = axios.create({ baseURL, timeout: 10000, headers: { "Content-Type": "application/json" }, });
// Auto-inject auth token
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem("auth_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Handle 401 globally
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("auth_token");
window.location.href = "/auth/login";
}
return Promise.reject(error);
}
);
}
async get<T>(url: string, config?: AxiosRequestConfig) { const response = await this.client.get<T>(url, config); return response.data; }
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig) { const response = await this.client.post<T>(url, data, config); return response.data; }
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig) { const response = await this.client.put<T>(url, data, config); return response.data; }
async delete<T>(url: string, config?: AxiosRequestConfig) { const response = await this.client.delete<T>(url, config); return response.data; } }
// Singleton instance export const httpApi = new HttpApi(import.meta.env.VITE_API_URL);
- Gateway Layer
Purpose: Abstract external resource access. Enable testing with fakes.
Example - Auth Gateway:
// features/auth/gateways/auth-gateway.ts import { httpApi } from "@/shared/services/http-api"; import type { User } from "../types/user";
export interface LoginRequest { email: string; password: string; }
export interface LoginResponse { user: User; token: string; }
// Gateway interface (contract) export interface AuthGateway { login(request: LoginRequest): Promise<LoginResponse>; logout(): Promise<void>; getCurrentUser(): Promise<User>; }
// Real implementation (HTTP) export class AuthHttpGateway implements AuthGateway { async login(request: LoginRequest): Promise<LoginResponse> { return httpApi.post<LoginResponse>("/auth/login", request); }
async logout(): Promise<void> { await httpApi.post("/auth/logout"); }
async getCurrentUser(): Promise<User> { return httpApi.get<User>("/auth/me"); } }
// Fake implementation for unit tests export class AuthFakeGateway implements AuthGateway { private shouldFail = false;
setShouldFail(value: boolean) { this.shouldFail = value; }
async login(request: LoginRequest): Promise<LoginResponse> { await new Promise((resolve) => setTimeout(resolve, 100));
if (this.shouldFail) {
throw new Error("Invalid credentials");
}
if (
request.email === "test@example.com" &&
request.password === "password123"
) {
return {
user: {
id: "1",
name: "Test User",
email: "test@example.com",
role: "user",
},
token: "fake-token-123",
};
}
throw new Error("Invalid credentials");
}
async logout(): Promise<void> { await new Promise((resolve) => setTimeout(resolve, 50)); }
async getCurrentUser(): Promise<User> { if (this.shouldFail) throw new Error("Unauthorized"); return { id: "1", name: "Test User", email: "test@example.com", role: "user", }; } }
- Gateway Injection (Context API)
Purpose: Provide gateways to components. Allow override in tests.
Example:
// app/providers/gateway-provider.tsx import { createContext, useContext, type ReactNode } from "react"; import { AuthGateway, AuthHttpGateway, } from "@/features/auth/gateways/auth-gateway";
interface Gateways { authGateway: AuthGateway; // Add more gateways as needed }
const GatewayContext = createContext<Gateways | null>(null);
interface GatewayProviderProps { children: ReactNode; gateways?: Partial<Gateways>; // Allow override for tests }
export function GatewayProvider({ children, gateways }: GatewayProviderProps) { const defaultGateways: Gateways = { authGateway: new AuthHttpGateway(), };
const value = { ...defaultGateways, ...gateways };
return ( <GatewayContext.Provider value={value}>{children}</GatewayContext.Provider> ); }
export function useGateways() { const context = useContext(GatewayContext); if (!context) { throw new Error("useGateways must be used within GatewayProvider"); } return context; }
- Store Layer (Zustand)
Purpose: Testable business logic, framework-agnostic state management.
Example:
// features/auth/stores/auth-store.ts import { create } from "zustand"; import type { User } from "../types/user";
interface AuthState { user: User | null; isAuthenticated: boolean; isLoading: boolean; error: string | null;
setUser: (user: User | null) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; reset: () => void; }
export const useAuthStore = create<AuthState>((set) => ({ user: null, isAuthenticated: false, isLoading: false, error: null,
setUser: (user) => set({ user, isAuthenticated: user !== null }), setLoading: (isLoading) => set({ isLoading }), setError: (error) => set({ error }), reset: () => set({ user: null, isAuthenticated: false, isLoading: false, error: null }), }));
Why Zustand:
-
✅ Minimal boilerplate
-
✅ Excellent TypeScript support
-
✅ Framework-agnostic (100% testable without React)
-
✅ Large community and ecosystem
-
✅ Superior DX compared to alternatives
- Page Layer (Use Cases)
Purpose: Orchestrate business logic by coordinating gateways, stores, and UI components.
Example:
// features/auth/pages/login-page.tsx import { useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useGateways } from "@/app/providers/gateway-provider"; import { useAuthStore } from "../stores/auth-store"; import { LoginForm } from "../components/login-form";
export function LoginPage() { const navigate = useNavigate(); const { authGateway } = useGateways(); // Injected gateway const { setUser, setLoading, setError, isLoading, error } = useAuthStore();
const [formError, setFormError] = useState<string | null>(null);
const handleLogin = async (email: string, password: string) => { setLoading(true); setError(null); setFormError(null);
try {
const { user, token } = await authGateway.login({ email, password });
localStorage.setItem("auth_token", token);
setUser(user);
navigate({ to: "/dashboard" });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Login failed";
setError(errorMessage);
setFormError(errorMessage);
} finally {
setLoading(false);
}
};
return ( <div className="flex min-h-screen items-center justify-center"> <LoginForm onSubmit={handleLogin} isLoading={isLoading} error={formError} /> </div> ); }
- Component Layer
Purpose: Pure UI components that receive props and emit events.
Example:
// features/auth/components/login-form.tsx import { useState } from "react"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input";
interface LoginFormProps { onSubmit: (email: string, password: string) => void; isLoading?: boolean; error?: string | null; }
export function LoginForm({ onSubmit, isLoading = false, error, }: LoginFormProps) { const [email, setEmail] = useState(""); const [password, setPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSubmit(email, password); };
return ( <form onSubmit={handleSubmit} className="w-full max-w-md space-y-4"> <div> <Input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={isLoading} aria-label="Email" /> </div>
<div>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
aria-label="Password"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? "Loading..." : "Login"}
</Button>
</form>
); }
State Management Strategy (MANDATORY)
Use the RIGHT tool for each state type:
- Global Client State → Zustand
For application-wide client state (auth, theme, preferences):
// shared/stores/theme-store.ts import { create } from "zustand";
export type Theme = "light" | "dark" | "system";
interface ThemeState { theme: Theme; setTheme: (theme: Theme) => void; }
export const useThemeStore = create<ThemeState>((set) => ({ theme: "system", setTheme: (theme) => set({ theme }), }));
- Server State → TanStack Query
For data from backend APIs with caching, revalidation, and synchronization:
// features/users/hooks/use-users-query.ts import { useQuery } from "@tanstack/react-query"; import { useGateways } from "@/app/providers/gateway-provider";
export function useUsersQuery() { const { userGateway } = useGateways();
return useQuery({ queryKey: ["users"], queryFn: () => userGateway.getUsers(), staleTime: 5 * 60 * 1000, // 5 minutes }); }
- Form State → TanStack Form
For form management with validation:
import { useForm } from "@tanstack/react-form"; import { zodValidator } from "@tanstack/zod-form-adapter"; import { z } from "zod";
const userSchema = z.object({ name: z.string().min(2), email: z.string().email(), });
export function UserForm() { const form = useForm({ defaultValues: { name: "", email: "" }, validatorAdapter: zodValidator(), validators: { onChange: userSchema }, onSubmit: async ({ value }) => console.log(value), });
// ... render form }
- URL State → TanStack Router
For URL parameters and search params:
// routes/users/$userId.tsx import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod";
const userSearchSchema = z.object({ tab: z.enum(["profile", "settings"]).optional(), });
export const Route = createFileRoute("/users/$userId")({ validateSearch: userSearchSchema, component: UserDetail, });
function UserDetail() { const { userId } = Route.useParams(); const { tab = "profile" } = Route.useSearch();
return ( <div> User {userId} - Tab: {tab} </div> ); }
- Local Component State → useState/useReducer
For component-specific state:
export function Counter() { const [count, setCount] = useState(0);
return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }
Testing Strategy (MANDATORY)
Test Pyramid
┌─────────────┐
╱ E2E Tests ╲ Few - Critical flows
╱ (Playwright) ╲
╱─────────────────────╲
╱ Integration Tests ╲ Some - Pages with fakes
╱ (Pages + Stores) ╲ ╱─────────────────────────╲ ╱ Unit Tests ╲ Many - Stores, utils ╱ (Stores, Utils, Comps) ╲ ─────────────────────────────
Store Tests (Unit - High Priority)
Zustand stores are 100% testable without React:
// features/auth/stores/auth-store.test.ts import { describe, it, expect, beforeEach } from "bun:test"; import { useAuthStore } from "./auth-store";
describe("AuthStore", () => { beforeEach(() => { useAuthStore.getState().reset(); });
it("should start with empty state", () => { const state = useAuthStore.getState(); expect(state.user).toBeNull(); expect(state.isAuthenticated).toBe(false); });
it("should update user and isAuthenticated when setting user", () => { const user = { id: "1", name: "Test", email: "test@example.com", role: "user", };
useAuthStore.getState().setUser(user);
expect(useAuthStore.getState().user).toEqual(user);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
}); });
Page Tests (Integration with Fake Gateways)
Test orchestration logic with injected fakes:
// features/auth/pages/login-page.test.tsx import { describe, it, expect, beforeEach } from "bun:test"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { GatewayProvider } from "@/app/providers/gateway-provider"; import { AuthFakeGateway } from "../gateways/auth-gateway"; import { LoginPage } from "./login-page"; import { useAuthStore } from "../stores/auth-store";
describe("LoginPage", () => { let fakeAuthGateway: AuthFakeGateway;
beforeEach(() => { fakeAuthGateway = new AuthFakeGateway(); useAuthStore.getState().reset(); });
it("should login successfully and update store", async () => { const user = userEvent.setup();
render(
<GatewayProvider gateways={{ authGateway: fakeAuthGateway }}>
<LoginPage />
</GatewayProvider>
);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /login/i }));
await waitFor(() => {
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
});
it("should show error when login fails", async () => { const user = userEvent.setup(); fakeAuthGateway.setShouldFail(true);
render(
<GatewayProvider gateways={{ authGateway: fakeAuthGateway }}>
<LoginPage />
</GatewayProvider>
);
await user.type(screen.getByLabelText(/email/i), "wrong@example.com");
await user.type(screen.getByLabelText(/password/i), "wrong");
await user.click(screen.getByRole("button", { name: /login/i }));
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
});
}); });
Component Tests (Unit - Pure UI)
// features/auth/components/login-form.test.tsx import { describe, it, expect, mock } from "bun:test"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { LoginForm } from "./login-form";
describe("LoginForm", () => { it("should render email and password fields", () => { render(<LoginForm onSubmit={mock()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it("should call onSubmit with correct values", async () => { const user = userEvent.setup(); const onSubmit = mock();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /login/i }));
expect(onSubmit).toHaveBeenCalledWith("test@example.com", "password123");
}); });
E2E Tests (Playwright - Critical Flows)
// e2e/auth/login.spec.ts import { test, expect } from "@playwright/test";
test.describe("Login Flow", () => { test("should login and redirect to dashboard", async ({ page }) => { await page.goto("/auth/login");
await page.fill('input[name="email"]', "test@example.com");
await page.fill('input[name="password"]', "password123");
await page.click('button:has-text("Login")');
await expect(page).toHaveURL("/dashboard");
await expect(page.locator("text=Welcome, Test User")).toBeVisible();
}); });
Naming Conventions (MANDATORY)
Files and Folders
-
Files: kebab-case with suffixes
-
Folders: kebab-case
features/auth/ ├── components/ │ ├── login-form.tsx # Component: kebab-case │ └── index.ts ├── pages/ │ ├── login-page.tsx # Suffix: -page.tsx │ └── index.ts ├── stores/ │ ├── auth-store.ts # Suffix: -store.ts │ └── index.ts ├── gateways/ │ ├── auth-gateway.ts # Suffix: -gateway.ts │ ├── auth-gateway.fake.ts # Suffix: -gateway.fake.ts │ └── index.ts
Code Elements
Components:
// ✅ Good export function LoginForm({ onSubmit }: LoginFormProps) {}
// ❌ Avoid export const loginForm = () => {}; // No arrow function exports export function Form() {} // Too generic
Pages:
// ✅ Good - "Page" suffix export function LoginPage() {}
// ❌ Avoid export function Login() {} // Confuses with component
Stores:
// ✅ Good - "use" + name + "Store" export const useAuthStore = create<AuthState>(...);
// ❌ Avoid export const authStore = create(...); // Missing "use" prefix
Gateways:
// ✅ Good - No "I" prefix export interface AuthGateway {} export class AuthHttpGateway implements AuthGateway {} export class AuthFakeGateway implements AuthGateway {}
// ❌ Avoid export interface IAuthGateway {} // No "I" prefix
TanStack Router Patterns (MANDATORY)
File-Based Routing
// routes/__root.tsx import { createRootRoute, Outlet } from "@tanstack/react-router";
export const Route = createRootRoute({ component: () => ( <div> <nav>Navigation</nav> <Outlet /> </div> ), });
// routes/index.tsx import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: HomePage, });
function HomePage() { return <h1>Home</h1>; }
Type-Safe Navigation
import { Link, useNavigate } from "@tanstack/react-router";
export function Navigation() { const navigate = useNavigate();
return ( <div> <Link to="/users/$userId" params={{ userId: "123" }}> View User </Link>
<button
onClick={() => {
navigate({ to: "/users/$userId", params: { userId: "456" } });
}}
>
Go to User
</button>
</div>
); }
Component Organization Principles (MANDATORY)
- Keep Components Small (< 150 lines)
// ✅ Good - Extract into smaller components export function UserDashboard() { return ( <div> <UserHeader /> <UserStats /> <UserActivity /> </div> ); }
- Extract Logic into Hooks (< 20 lines in component)
// ✅ Good export function UserList() { const { users, loading, error } = useUsers();
if (loading) return <Loading />; if (error) return <Error />;
return <div>{/* Render users */}</div>; }
- One Component Per File
// ✅ Good - Separate files // user-card.tsx export function UserCard() {}
// user-avatar.tsx export function UserAvatar() {}
Critical Rules
NEVER:
-
Use Clean Architecture layers (domain/application/infrastructure)
-
Import stores in components (pass data via props)
-
Call HTTP directly from components (use gateways)
-
Use any type (use unknown with type guards)
-
Skip TypeScript types
-
Use npm, pnpm, yarn (use Bun)
-
Create components > 150 lines
-
Keep logic > 20 lines in components
ALWAYS:
-
Organize by features (feature-based structure)
-
Pages orchestrate logic (use gateways + stores)
-
Externalize state to Zustand stores
-
Inject gateways via Context API
-
Write tests for stores (unit) and pages (integration with fakes)
-
Use functional components with TypeScript
-
One component per file
-
Extract logic into custom hooks
-
Use Bun for all package management
-
Run bun run craft after creating/moving files
Deliverables
When helping users, provide:
-
Feature Folder Structure: Organized with pages/, components/, stores/, gateways/
-
Gateway Definitions: Interface + HTTP implementation + Fake implementation
-
Gateway Provider: Context API setup for dependency injection
-
Zustand Stores: Framework-agnostic state management with actions
-
Pages: Orchestration logic using gateways and stores
-
Components: Pure UI components with props
-
Router Configuration: TanStack Router setup with type safety
-
Test Examples: Store tests, page tests with fakes, component tests, E2E
-
Configuration Files: Vite, TypeScript, TanStack configurations
Summary
Frontend architecture focuses on:
-
Simplicity: No unnecessary layers, direct code, pragmatic patterns
-
Testability: Zustand stores (pure), pages with fake gateways, isolated components
-
Maintainability: Feature-based structure, clear responsibilities, consistent patterns
-
Type Safety: Strong typing, Zod validation, TanStack Router
-
Performance: Code splitting, lazy loading, optimistic updates
-
User Experience: Error handling, loading states, toast notifications
Remember: Good architecture makes change easy. Build systems that are simple, testable, and maintainable. Avoid over-engineering. Focus on delivering value.
Reference:
-
Architecture design: docs/plans/2025-10-24-simplified-frontend-architecture-design.md
-
Project standards: project-standards skill
-
UI/UX: ui-designer skill