Created: January 23, 2026
Last Updated: January 23, 2026
Status: Example Project
Frontend:
- TypeScript 5.3
- React 18.2 (Functional Components + Hooks)
- Vite 5.0 (build tool)
- React Router 6.20
State Management:
- Zustand 4.4 (lightweight state)
- TanStack Query 5.0 (server state)
Styling:
- Tailwind CSS 3.4
- PostCSS
- clsx (conditional classes)
Testing:
- Vitest 1.2 (unit tests)
- @testing-library/react 14.1 (component testing)
- @testing-library/user-event 14.5 (interaction testing)
- Happy DOM (DOM implementation)
Code Quality:
- ESLint 8.x
- Prettier 3.x
- TypeScript strict mode
Key Libraries:
- Axios (HTTP client)
- React Hook Form 7.48 (form management)
- Zod 3.22 (schema validation)
- date-fns (date utilities)
ALWAYS run these commands before claiming work is complete:
| Changed | Command | Expected Result |
|---|---|---|
| TypeScript | npm run type-check |
No errors |
| Unit Tests | npm run test:run |
All tests pass |
| Linting | npm run lint |
No errors |
| Build | npm run build |
Successful build, no warnings |
Never use npm test alone - it hangs! Always use npm run test:run for CI/scripts.
Pattern: FC with Typed Props + Hooks + Custom Hooks
// src/components/ArticleCard.tsx
import { FC, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import type { Article } from '@/types/api'
interface ArticleCardProps {
article: Article
showAuthor?: boolean
onEdit?: (id: number) => void
onDelete?: (id: number) => void
}
export const ArticleCard: FC<ArticleCardProps> = ({
article,
showAuthor = true,
onEdit,
onDelete
}) => {
const navigate = useNavigate()
const handleView = useCallback(() => {
navigate(`/articles/${article.id}`)
}, [article.id, navigate])
const handleEdit = useCallback(() => {
onEdit?.(article.id)
}, [article.id, onEdit])
const formattedDate = new Date(article.created_at).toLocaleDateString()
return (
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<h3 className="text-xl font-bold mb-2">{article.title}</h3>
{showAuthor && (
<p className="text-sm text-gray-600 mb-2">
By {article.author_name}
</p>
)}
<p className="text-gray-700 mb-4">{article.excerpt}</p>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">{formattedDate}</span>
<div className="space-x-2">
<button
onClick={handleView}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Read More
</button>
{onEdit && (
<button
onClick={handleEdit}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Edit
</button>
)}
</div>
</div>
</div>
)
}Why:
FC<Props>provides type safety for componentuseCallbackprevents unnecessary re-renders- Optional chaining (
onEdit?.()) handles optional callbacks - Destructured props with defaults for clarity
- Tailwind classes for styling consistency
Gotchas:
- ❌ Don't use
React.FC- deprecated, useFCfrom React - ❌ Don't forget
useCallbackfor event handlers passed to children - ✅ Always define interface for props (even if empty)
- ✅ Use
?.for optional callbacks to prevent errors
Pattern: Extract Logic to Reusable Hooks
// src/hooks/useArticles.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/client'
import type { Article, ArticleCreate } from '@/types/api'
export const useArticles = () => {
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.get<Article[]>('/articles').then(res => res.data),
})
const createMutation = useMutation({
mutationFn: (article: ArticleCreate) =>
api.post<Article>('/articles', article).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] })
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Article> }) =>
api.patch<Article>(`/articles/${id}`, data).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] })
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/articles/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] })
},
})
return {
articles: data ?? [],
isLoading,
error,
createArticle: createMutation.mutate,
updateArticle: updateMutation.mutate,
deleteArticle: deleteMutation.mutate,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
}
}Why:
- Separates data fetching from UI logic
- TanStack Query handles caching, loading, error states
- Automatic cache invalidation on mutations
- Reusable across multiple components
Usage:
// src/pages/ArticlesPage.tsx
import { useArticles } from '@/hooks/useArticles'
import { ArticleCard } from '@/components/ArticleCard'
export const ArticlesPage = () => {
const { articles, isLoading, deleteArticle } = useArticles()
if (isLoading) return <div>Loading...</div>
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map(article => (
<ArticleCard
key={article.id}
article={article}
onDelete={deleteArticle}
/>
))}
</div>
)
}Gotchas:
- ❌ Don't call hooks conditionally or in loops
- ❌ Don't forget to invalidate queries after mutations
- ✅ Always provide
queryKeyfor proper caching - ✅ Use
??fallback for data to handle undefined
Pattern: React Hook Form + Zod Validation
// src/components/ArticleForm.tsx
import { FC } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const articleSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(10, 'Content must be at least 10 characters'),
published: z.boolean(),
tags: z.array(z.string()).optional(),
})
type ArticleFormData = z.infer<typeof articleSchema>
interface ArticleFormProps {
initialData?: Partial<ArticleFormData>
onSubmit: (data: ArticleFormData) => void
isSubmitting?: boolean
}
export const ArticleForm: FC<ArticleFormProps> = ({
initialData,
onSubmit,
isSubmitting = false
}) => {
const {
register,
handleSubmit,
formState: { errors, isDirty }
} = useForm<ArticleFormData>({
resolver: zodResolver(articleSchema),
defaultValues: initialData
})
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Title
</label>
<input
{...register('title')}
id="title"
type="text"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.title && (
<p className="text-red-500 text-sm mt-1">{errors.title.message}</p>
)}
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-1">
Content
</label>
<textarea
{...register('content')}
id="content"
rows={8}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.content && (
<p className="text-red-500 text-sm mt-1">{errors.content.message}</p>
)}
</div>
<div className="flex items-center">
<input
{...register('published')}
id="published"
type="checkbox"
className="mr-2"
/>
<label htmlFor="published" className="text-sm">
Publish immediately
</label>
</div>
<button
type="submit"
disabled={isSubmitting || !isDirty}
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Article'}
</button>
</form>
)
}Why:
- React Hook Form minimizes re-renders (uncontrolled inputs)
- Zod provides TypeScript-first schema validation
zodResolverintegrates validation with form state- Type inference (
z.infer) ensures type safety
Gotchas:
- ❌ Don't use controlled inputs unless necessary (performance)
- ❌ Don't validate manually - let Zod handle it
- ✅ Always use
{...register('fieldName')}for inputs - ✅ Use
isDirtyto prevent unchanged form submissions
Pattern: Create Type-Safe Stores
// src/stores/authStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: number
username: string
email: string
}
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
// Actions
login: (user: User, token: string) => void
logout: () => void
updateUser: (user: Partial<User>) => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: (user, token) => set({
user,
token,
isAuthenticated: true
}),
logout: () => set({
user: null,
token: null,
isAuthenticated: false
}),
updateUser: (userData) => set((state) => ({
user: state.user ? { ...state.user, ...userData } : null
}))
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated
})
}
)
)Usage:
// src/components/Header.tsx
import { useAuthStore } from '@/stores/authStore'
export const Header = () => {
const { user, isAuthenticated, logout } = useAuthStore()
return (
<header>
{isAuthenticated ? (
<>
<span>Welcome, {user?.username}</span>
<button onClick={logout}>Logout</button>
</>
) : (
<a href="/login">Login</a>
)}
</header>
)
}Why:
- Minimal boilerplate compared to Redux/Context
- Built-in persistence middleware
- TypeScript support out of the box
- Selective subscriptions (only re-render what changed)
Gotchas:
- ❌ Don't put server state in Zustand (use TanStack Query)
- ❌ Don't mutate state directly - use
set() - ✅ Use
persistfor localStorage sync - ✅ Partition stores by domain (auth, UI, settings)
Pattern: Type-Safe Routes with React Router
// src/router/index.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { Layout } from '@/components/Layout'
import { HomePage } from '@/pages/HomePage'
import { ArticlesPage } from '@/pages/ArticlesPage'
import { ArticleDetailPage } from '@/pages/ArticleDetailPage'
import { NotFoundPage } from '@/pages/NotFoundPage'
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
errorElement: <NotFoundPage />,
children: [
{
index: true,
element: <HomePage />
},
{
path: 'articles',
element: <ArticlesPage />
},
{
path: 'articles/:id',
element: <ArticleDetailPage />
}
]
}
])
export const Router = () => <RouterProvider router={router} />Route Parameters:
// src/pages/ArticleDetailPage.tsx
import { useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/api/client'
export const ArticleDetailPage = () => {
const { id } = useParams<{ id: string }>()
const { data: article, isLoading } = useQuery({
queryKey: ['article', id],
queryFn: () => api.get(`/articles/${id}`).then(res => res.data),
enabled: !!id
})
if (isLoading) return <div>Loading...</div>
if (!article) return <div>Article not found</div>
return (
<article>
<h1>{article.title}</h1>
<p>{article.content}</p>
</article>
)
}Gotchas:
- ❌ Don't use
useParams()without type argument - ❌ Don't forget
enabled: !!idfor conditional queries - ✅ Use
errorElementfor error boundaries - ✅ Use nested routes for layouts
Pattern: Testing Library + User Event
// src/components/__tests__/ArticleCard.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ArticleCard } from '../ArticleCard'
describe('ArticleCard', () => {
const mockArticle = {
id: 1,
title: 'Test Article',
excerpt: 'Test excerpt',
author_name: 'John Doe',
created_at: '2026-01-01T00:00:00Z'
}
it('renders article information', () => {
render(<ArticleCard article={mockArticle} />)
expect(screen.getByText('Test Article')).toBeInTheDocument()
expect(screen.getByText('Test excerpt')).toBeInTheDocument()
expect(screen.getByText(/By John Doe/)).toBeInTheDocument()
})
it('calls onEdit when edit button clicked', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
render(<ArticleCard article={mockArticle} onEdit={onEdit} />)
const editButton = screen.getByRole('button', { name: /edit/i })
await user.click(editButton)
expect(onEdit).toHaveBeenCalledWith(1)
expect(onEdit).toHaveBeenCalledTimes(1)
})
it('hides author when showAuthor is false', () => {
render(<ArticleCard article={mockArticle} showAuthor={false} />)
expect(screen.queryByText(/By John Doe/)).not.toBeInTheDocument()
})
})Pattern: Test Custom Hooks
// src/hooks/__tests__/useArticles.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useArticles } from '../useArticles'
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('useArticles', () => {
it('fetches articles on mount', async () => {
const { result } = renderHook(() => useArticles(), {
wrapper: createWrapper()
})
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.articles).toBeDefined()
})
})These rules MUST be followed:
- TypeScript Strict Mode: All files must pass
tsc --noEmit - Component Props: Always define interface for props (no implicit
any) - Hooks Rules: Never call hooks conditionally or in loops
- State Management:
- Server state → TanStack Query
- Client state → Zustand or local
useState
- Error Boundaries: All routes must have
errorElement - Accessibility: All interactive elements must have proper ARIA labels
- Testing: Every component must have at least one test
Problem:
// ❌ BAD: Creates new function on every render
<ArticleCard onEdit={(id) => handleEdit(id)} />Solution:
// ✅ GOOD: useCallback prevents re-creation
const handleEdit = useCallback((id: number) => {
console.log('Edit', id)
}, [])
<ArticleCard onEdit={handleEdit} />Problem:
// ❌ BAD: ESLint warning, stale closure
useEffect(() => {
fetchData(userId)
}, [])Solution:
// ✅ GOOD: Include all dependencies
useEffect(() => {
fetchData(userId)
}, [userId])Problem:
// ❌ BAD: Same key for different data
useQuery({ queryKey: ['articles'], queryFn: () => fetchArticle(id) })Solution:
// ✅ GOOD: Include dynamic values in key
useQuery({ queryKey: ['article', id], queryFn: () => fetchArticle(id) })Problem:
// ❌ BAD: Manual validation is error-prone
const [errors, setErrors] = useState({})
const validate = () => { /* complex logic */ }Solution:
// ✅ GOOD: Use Zod + React Hook Form
const schema = z.object({ title: z.string().min(1) })
const { register } = useForm({ resolver: zodResolver(schema) })Problem:
// ❌ BAD: Loses type safety
const data: any = await fetchData()Solution:
// ✅ GOOD: Define proper types
interface Article { id: number; title: string }
const data: Article = await fetchData()- React: Industry standard, huge ecosystem, battle-tested
- Vite: Fast HMR, native ESM, better DX than CRA
- TypeScript: Catches bugs at compile-time, self-documenting code
- 10x less boilerplate
- No providers needed
- Better TypeScript inference
- Middleware ecosystem (persist, devtools)
- Eliminates 90% of async state management
- Automatic caching, refetching, pagination
- Built-in loading/error states
- Optimistic updates
- Minimal re-renders (uncontrolled inputs)
- Type-safe validation
- Great DX with TypeScript inference
- Tiny bundle size (~9KB)
Component files:
src/components/
├── ArticleCard/
│ ├── ArticleCard.tsx
│ ├── ArticleCard.test.tsx
│ └── index.ts # Re-export
Feature-based structure (for large apps):
src/features/
├── articles/
│ ├── components/
│ ├── hooks/
│ ├── api/
│ └── types/
- Read this document - Understand patterns before coding
- Run tests -
npm run test:runto verify setup - Build sample component - Follow ArticleCard pattern
- Create custom hook - Practice TanStack Query
- Write tests - Aim for >80% coverage
- Review PRs - Learn from team patterns
Remember: This is a living document. Update patterns as the codebase evolves.