Frontend Development Patterns
This skill provides comprehensive guidance for modern frontend development using React, Next.js, TypeScript, and related technologies.
Component Architecture
Component Composition Patterns
Container/Presentational Pattern:
// Presentational component (pure, reusable) interface UserCardProps { name: string; email: string; avatar: string; onEdit: () => void; }
function UserCard({ name, email, avatar, onEdit }: UserCardProps) { return ( <div className="user-card"> <img src={avatar} alt={name} /> <h3>{name}</h3> <p>{email}</p> <button onClick={onEdit}>Edit</button> </div> ); }
// Container component (handles logic, state, data fetching) function UserCardContainer({ userId }: { userId: string }) { const { data: user, isLoading } = useUser(userId); const { mutate: updateUser } = useUpdateUser();
if (isLoading) return <Skeleton />; if (!user) return <NotFound />;
return <UserCard {...user} onEdit={() => updateUser(user.id)} />; }
Compound Components Pattern:
// Flexible, composable API <Tabs defaultValue="profile"> <TabsList> <TabsTrigger value="profile">Profile</TabsTrigger> <TabsTrigger value="settings">Settings</TabsTrigger> </TabsList> <TabsContent value="profile"> <ProfileForm /> </TabsContent> <TabsContent value="settings"> <SettingsForm /> </TabsContent> </Tabs>
Component Organization
components/ ├── ui/ # Primitive components (buttons, inputs) │ ├── button.tsx │ ├── input.tsx │ └── card.tsx ├── forms/ # Form components │ ├── login-form.tsx │ └── register-form.tsx ├── features/ # Feature-specific components │ ├── user-profile/ │ │ ├── profile-header.tsx │ │ ├── profile-stats.tsx │ │ └── index.ts │ └── dashboard/ │ ├── dashboard-grid.tsx │ └── dashboard-card.tsx └── layouts/ # Layout components ├── main-layout.tsx └── auth-layout.tsx
State Management
Local State (useState)
Use for:
-
Component-specific UI state
-
Form inputs
-
Toggles, modals
function SearchBar() { const [query, setQuery] = useState(''); const [isOpen, setIsOpen] = useState(false);
return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} /> {isOpen && <SearchResults query={query} />} </div> ); }
Global State (Zustand)
Use for:
-
User authentication state
-
Theme preferences
-
Shopping cart
-
Cross-component shared state
import create from 'zustand';
interface UserStore { user: User | null; setUser: (user: User) => void; logout: () => void; }
export const useUserStore = create<UserStore>((set) => ({ user: null, setUser: (user) => set({ user }), logout: () => set({ user: null }), }));
// Usage function Header() { const user = useUserStore((state) => state.user); const logout = useUserStore((state) => state.logout);
return <div>{user ? user.name : 'Guest'}</div>; }
Server State (React Query / TanStack Query)
Use for:
-
API data fetching
-
Caching API responses
-
Optimistic updates
-
Background refetching
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch data function UserProfile({ userId }: { userId: string }) { const { data, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), staleTime: 5 * 60 * 1000, // 5 minutes });
if (isLoading) return <Skeleton />; if (error) return <Error error={error} />;
return <div>{data.name}</div>; }
// Mutations with optimistic updates function useUpdateUser() { const queryClient = useQueryClient();
return useMutation({ mutationFn: (user: User) => api.updateUser(user), onMutate: async (newUser) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['user', newUser.id] });
// Snapshot previous value
const previous = queryClient.getQueryData(['user', newUser.id]);
// Optimistically update
queryClient.setQueryData(['user', newUser.id], newUser);
return { previous };
},
onError: (err, newUser, context) => {
// Rollback on error
queryClient.setQueryData(['user', newUser.id], context?.previous);
},
onSettled: (newUser) => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['user', newUser.id] });
},
}); }
Performance Optimization
- Memoization
useMemo (expensive calculations):
function ProductList({ products }: { products: Product[] }) { const sortedProducts = useMemo( () => products.sort((a, b) => b.price - a.price), [products] );
return <div>{sortedProducts.map(...)}</div>; }
useCallback (prevent re-renders):
function Parent() { const [count, setCount] = useState(0);
// ✅ Memoized - Child won't re-render unless count changes const handleClick = useCallback(() => { setCount(c => c + 1); }, []);
return <Child onClick={handleClick} />; }
const Child = memo(function Child({ onClick }: { onClick: () => void }) { console.log('Child rendered'); return <button onClick={onClick}>Click</button>; });
React.memo (prevent component re-renders):
const ExpensiveComponent = memo(function ExpensiveComponent({ data }) { // Only re-renders if data changes return <div>{/* expensive rendering */}</div>; });
- Code Splitting
Route-based splitting (Next.js automatic):
// app/dashboard/page.tsx - automatically code split export default function DashboardPage() { return <Dashboard />; }
Component-level splitting:
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('@/components/heavy-chart'), { loading: () => <Skeleton />, ssr: false, // Don't render on server });
function Analytics() { return <HeavyChart data={chartData} />; }
- Image Optimization
import Image from 'next/image';
// ✅ Optimized - Next.js Image component <Image src="/hero.jpg" alt="Hero image" width={800} height={600} priority // Load immediately for LCP placeholder="blur" blurDataURL="data:image/..." />
// ❌ Not optimized <img src="/hero.jpg" alt="Hero" />
- Lazy Loading
import { lazy, Suspense } from 'react';
const Comments = lazy(() => import('./comments'));
function Post() { return ( <div> <PostContent /> <Suspense fallback={<CommentsSkeleton />}> <Comments postId={postId} /> </Suspense> </div> ); }
- Virtual Scrolling
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) { const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50, });
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: ${virtualizer.getTotalSize()}px }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ${virtualRow.size}px,
transform: translateY(${virtualRow.start}px),
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
Accessibility (a11y)
Semantic HTML
// ✅ Semantic <nav> <ul> <li><a href="/home">Home</a></li> <li><a href="/about">About</a></li> </ul> </nav>
// ❌ Non-semantic <div> <div> <div onClick={goHome}>Home</div> <div onClick={goAbout}>About</div> </div> </div>
ARIA Attributes
<button aria-label="Close dialog" aria-expanded={isOpen} aria-controls="dialog-content" onClick={toggle}
<X aria-hidden="true" /> </button>
<div id="dialog-content" role="dialog" aria-modal="true" aria-labelledby="dialog-title"
<h2 id="dialog-title">Dialog Title</h2> {content} </div>
Keyboard Navigation
function Dropdown() { const [isOpen, setIsOpen] = useState(false); const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setFocusedIndex((i) => Math.min(i + 1, items.length - 1)); break; case 'ArrowUp': e.preventDefault(); setFocusedIndex((i) => Math.max(i - 1, 0)); break; case 'Enter': selectItem(items[focusedIndex]); break; case 'Escape': setIsOpen(false); break; } };
return ( <div onKeyDown={handleKeyDown} role="combobox"> {/* dropdown content */} </div> ); }
Focus Management
import { useRef, useEffect } from 'react';
function Modal({ isOpen, onClose }: ModalProps) { const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => { if (isOpen) { // Focus close button when modal opens closeButtonRef.current?.focus();
// Trap focus within modal
const handleTab = (e: KeyboardEvent) => {
// Implement focus trap logic
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}
}, [isOpen]);
if (!isOpen) return null;
return ( <div role="dialog" aria-modal="true"> <button ref={closeButtonRef} onClick={onClose}> Close </button> {content} </div> ); }
Form Patterns
Controlled Forms with Validation
import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod';
const schema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), age: z.number().min(18, 'Must be 18 or older'), });
type FormData = z.infer<typeof schema>;
function RegistrationForm() { const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({ resolver: zodResolver(schema), });
const onSubmit = async (data: FormData) => { await api.register(data); };
return ( <form onSubmit={handleSubmit(onSubmit)}> <div> <input {...register('email')} type="email" /> {errors.email && <span>{errors.email.message}</span>} </div>
<div>
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
); }
Form State Management
// Optimistic updates const { mutate } = useMutation({ mutationFn: updateUser, onMutate: async (newData) => { // Cancel outgoing queries await queryClient.cancelQueries({ queryKey: ['user', userId] });
// Snapshot previous
const previous = queryClient.getQueryData(['user', userId]);
// Optimistically update UI
queryClient.setQueryData(['user', userId], newData);
return { previous };
}, onError: (err, newData, context) => { // Rollback on error queryClient.setQueryData(['user', userId], context?.previous); toast.error('Update failed'); }, onSuccess: () => { toast.success('Updated successfully'); }, });
Error Handling
Error Boundaries
import { Component, ReactNode } from 'react';
interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { hasError: boolean; error?: Error; }
class ErrorBoundary extends Component<Props, State> { constructor(props: Props) { super(props); this.state = { hasError: false }; }
static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; }
componentDidCatch(error: Error, errorInfo: any) { console.error('Error boundary caught:', error, errorInfo); // Log to error tracking service logErrorToService(error, errorInfo); }
render() { if (this.state.hasError) { return this.props.fallback || ( <div> <h2>Something went wrong</h2> <button onClick={() => this.setState({ hasError: false })}> Try again </button> </div> ); }
return this.props.children;
} }
// Usage <ErrorBoundary fallback={<ErrorFallback />}> <App /> </ErrorBoundary>
Async Error Handling
function DataComponent() { const { data, error, isError, isLoading } = useQuery({ queryKey: ['data'], queryFn: fetchData, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), });
if (isLoading) return <Skeleton />; if (isError) return <ErrorDisplay error={error} />;
return <DisplayData data={data} />; }
Responsive Design
Mobile-First Approach
// Tailwind CSS (mobile-first) <div className=" w-full /* Full width on mobile / md:w-1/2 / Half width on tablets / lg:w-1/3 / Third width on desktop / p-4 / Padding 16px / md:p-6 / Padding 24px on tablets+ */ "> Content </div>
Responsive Hooks
import { useMediaQuery } from '@/hooks/use-media-query';
function ResponsiveLayout() { const isMobile = useMediaQuery('(max-width: 768px)'); const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)'); const isDesktop = useMediaQuery('(min-width: 1025px)');
if (isMobile) return <MobileLayout />; if (isTablet) return <TabletLayout />; return <DesktopLayout />; }
Data Fetching Strategies
Server Components (Next.js 14+)
// app/users/page.tsx - Server Component async function UsersPage() { // Fetched on server const users = await db.user.findMany();
return <UserList users={users} />; }
Client Components with React Query
'use client';
function UserList() { const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, });
if (isLoading) return <UsersLoading />;
return <div>{users.map(user => <UserCard key={user.id} {...user} />)}</div>; }
Parallel Data Fetching
function Dashboard() { const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser }); const { data: stats } = useQuery({ queryKey: ['stats'], queryFn: fetchStats }); const { data: posts } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
// All three queries run in parallel return <div>...</div>; }
Dependent Queries
function UserPosts({ userId }: { userId: string }) { const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });
const { data: posts } = useQuery({ queryKey: ['posts', user?.id], queryFn: () => fetchUserPosts(user!.id), enabled: !!user, // Only fetch after user is loaded });
return <div>...</div>; }
TypeScript Patterns
Prop Types
// Basic props interface ButtonProps { children: ReactNode; onClick: () => void; variant?: 'primary' | 'secondary'; disabled?: boolean; }
// Props with generic interface ListProps<T> { items: T[]; renderItem: (item: T) => ReactNode; keyExtractor: (item: T) => string; }
// Props extending HTML attributes interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { label: string; error?: string; }
Type-Safe API Responses
// API response types interface ApiResponse<T> { data: T; error?: never; }
interface ApiError { data?: never; error: { code: string; message: string; }; }
type ApiResult<T> = ApiResponse<T> | ApiError;
// Usage
async function fetchUser(id: string): Promise<ApiResult<User>> {
const response = await fetch(/api/users/${id});
return response.json();
}
Testing Patterns
Component Testing
import { render, screen, fireEvent } from '@testing-library/react'; import { LoginForm } from './login-form';
test('submits form with email and password', async () => { const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'test@example.com' }, });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' }, });
fireEvent.click(screen.getByRole('button', { name: 'Login' }));
await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123', }); }); });
Mock API Calls
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { rest } from 'msw'; import { setupServer } from 'msw/node';
const server = setupServer( rest.get('/api/users/:id', (req, res, ctx) => { return res(ctx.json({ id: req.params.id, name: 'Test User', email: 'test@example.com', })); }) );
beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
test('displays user data', async () => { const queryClient = new QueryClient();
render( <QueryClientProvider client={queryClient}> <UserProfile userId="123" /> </QueryClientProvider> );
expect(await screen.findByText('Test User')).toBeInTheDocument(); });
Build Optimization
Bundle Analysis
Next.js bundle analyzer
npm install @next/bundle-analyzer
next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', });
module.exports = withBundleAnalyzer({ // config });
Run analysis
ANALYZE=true npm run build
Tree Shaking
// ✅ Named imports (tree-shakeable) import { Button } from '@/components/ui/button';
// ❌ Namespace import (includes everything) import * as UI from '@/components/ui';
Dynamic Imports
// Import only when needed async function handleExport() { const { exportToPDF } = await import('@/lib/pdf-export'); await exportToPDF(data); }
Common Frontend Mistakes to Avoid
-
Prop drilling: Use Context or state management library instead
-
Unnecessary re-renders: Use memo, useMemo, useCallback appropriately
-
Missing loading states: Always show loading indicators
-
No error boundaries: Catch errors before they break the app
-
Inline functions in JSX: Causes re-renders, use useCallback
-
Large bundle sizes: Code split and lazy load
-
Missing alt text: All images need descriptive alt text
-
Inaccessible forms: Use proper labels and ARIA
-
Console.log in production: Remove or use proper logging
-
Mixing server and client code: Know Next.js boundaries
Performance Metrics (Core Web Vitals)
LCP (Largest Contentful Paint)
Target: < 2.5 seconds
Optimize:
-
Preload critical images
-
Use Next.js Image component
-
Minimize render-blocking resources
-
Use CDN for assets
FID (First Input Delay)
Target: < 100 milliseconds
Optimize:
-
Minimize JavaScript execution
-
Code split large bundles
-
Use web workers for heavy computation
-
Defer non-critical JavaScript
CLS (Cumulative Layout Shift)
Target: < 0.1
Optimize:
-
Set explicit width/height on images
-
Reserve space for ads/embeds
-
Avoid inserting content above existing content
-
Use CSS transforms instead of layout properties
When to Use This Skill
Use this skill when:
-
Building React or Next.js components
-
Implementing frontend features
-
Optimizing frontend performance
-
Debugging rendering issues
-
Setting up state management
-
Implementing forms
-
Ensuring accessibility
-
Working with responsive design
-
Fetching and caching data
-
Testing frontend code
Remember: Modern frontend development is about creating fast, accessible, and delightful user experiences. Follow these patterns to build UIs that users love.