ποΈ Lesson 8.5: Architecture Best Practices
Welcome to the final lesson of Module 8! Great code isn't just about writing featuresβit's about building applications that scale, adapt to change, and remain maintainable as your team and codebase grow. In this comprehensive lesson, you'll learn professional architecture patterns that separate great React applications from good ones. Whether you're building a small side project or an enterprise application, these architectural principles will serve you throughout your career as a React developer.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Understand the principles of scalable React architecture
- Implement feature-based folder structures
- Organize components by responsibility and reusability
- Apply separation of concerns to React applications
- Extract business logic into custom hooks
- Design service layers for API communication
- Structure TypeScript types and interfaces effectively
- Implement proper error handling strategies
- Design for testability from the start
- Make architectural decisions that support growth
Estimated Time: 75-90 minutes
Prerequisites: Lessons 8.1-8.4 (State Management, Zustand, Redux Toolkit, React Query), All previous modules
π In This Lesson
π― Principles of Good Architecture
Before diving into specific patterns, let's understand the fundamental principles that guide architectural decisions in React applications. These principles apply regardless of project size or specific technologies used.
The Core Principles
π Architecture Principles
Good architecture is about making your codebase easy to understand, modify, and extend. It prioritizes maintainability over cleverness, clarity over brevity, and consistency over individual preferences. The goal is to minimize the cognitive load required to work with your code, both for yourself in the future and for your teammates.
1. Separation of Concerns
Each part of your application should have a single, well-defined responsibility. When components, hooks, and utilities each focus on one thing, they become easier to understand, test, and reuse.
// β BAD - Component doing too much
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, []);
const saveUser = (data) => {
fetch('/api/user', {
method: 'PUT',
body: JSON.stringify(data)
}).then(/* ... */);
};
return (
<div>
{loading ? <Spinner /> : (
<form onSubmit={/* validation logic */}>
{/* lots of form fields and UI logic */}
</form>
)}
</div>
);
}
// β
GOOD - Separated concerns
function UserProfile() {
const { user, isLoading } = useUser();
return (
<div>
{isLoading ? <Spinner /> : <UserForm user={user} />}
</div>
);
}
// Custom hook handles data fetching
function useUser() {
return useQuery({
queryKey: ['user'],
queryFn: userService.getProfile
});
}
// Service handles API calls
const userService = {
getProfile: () => fetch('/api/user').then(r => r.json()),
updateProfile: (data) => fetch('/api/user', {
method: 'PUT',
body: JSON.stringify(data)
})
};
// Component handles only UI and user interactions
function UserForm({ user }: { user: User }) {
const updateMutation = useUpdateUser();
// Form logic...
}
2. DRY (Don't Repeat Yourself)
Duplicated code multiplies maintenance work and increases the chance of bugs. Extract common logic into reusable functions, hooks, and components.
// β BAD - Repeated logic
function UsersList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
// ... component JSX
}
function PostsList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
// ... component JSX
}
// β
GOOD - Reusable hook
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setData(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Now both components are simple
function UsersList() {
const { data: users, loading, error } = useFetch<User[]>('/api/users');
// ... component JSX
}
function PostsList() {
const { data: posts, loading, error } = useFetch<Post[]>('/api/posts');
// ... component JSX
}
3. KISS (Keep It Simple, Stupid)
Simple solutions are easier to understand, test, and maintain. Avoid over-engineering and premature optimization.
β Signs of Good Simplicity
- A new team member can understand a component in under 5 minutes
- Testing feels natural, not like fighting the code
- Naming clearly describes what something does
- You don't need comments to explain "why" (but you might need them for "what")
- Changes in one area rarely require changes in unrelated areas
4. Single Responsibility Principle
Each component, function, or module should have one reason to change. This makes code predictable and reduces the ripple effect of modifications.
// β BAD - Component has multiple responsibilities
function UserDashboard() {
return (
<div>
{/* Analytics tracking logic */}
{/* User data fetching */}
{/* Navigation rendering */}
{/* User profile display */}
{/* Settings management */}
{/* Notifications handling */}
</div>
);
}
// β
GOOD - Each component has one responsibility
function UserDashboard() {
useAnalytics('dashboard-view');
const { user } = useUser();
return (
<DashboardLayout>
<UserHeader user={user} />
<UserStats userId={user.id} />
<RecentActivity userId={user.id} />
<QuickActions />
</DashboardLayout>
);
}
5. Composition Over Inheritance
React's component model encourages composition. Build complex UIs by combining simple, focused components rather than creating deep inheritance hierarchies.
// β
GOOD - Composition pattern
function Card({ children }: { children: React.ReactNode }) {
return <div className="card">{children}</div>;
}
function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>;
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>;
}
// Compose them together
function UserCard({ user }: { user: User }) {
return (
<Card>
<CardHeader>
<h2>{user.name}</h2>
</CardHeader>
<CardBody>
<p>{user.bio}</p>
</CardBody>
</Card>
);
}
π‘ The Testing Principle
A good architecture test: If a component is hard to test, it's probably doing too much. Difficulty testing is often a sign that you need to break things down or separate concerns better.
Architecture Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| God Components | Components that do everything | Break into smaller, focused components |
| Prop Drilling | Passing props through many layers | Use Context, state management, or composition |
| Mixed Concerns | Business logic in components | Extract to custom hooks or services |
| Tight Coupling | Components depend on implementation details | Use interfaces and dependency injection |
| Premature Abstraction | Creating abstractions before needed | Wait for the third use case |
π Feature-Based Folder Structure
As applications grow, organizing files by type (all components together, all hooks together) becomes unwieldy. A feature-based structure groups related files together, making it easier to understand and modify features as complete units.
Traditional vs Feature-Based Structure
β Type-Based (Doesn't Scale)
src/
βββ components/
β βββ UserList.tsx
β βββ UserCard.tsx
β βββ UserForm.tsx
β βββ PostList.tsx
β βββ PostCard.tsx
β βββ PostForm.tsx
β βββ CommentList.tsx
β βββ ...50+ more
βββ hooks/
β βββ useUsers.ts
β βββ usePosts.ts
β βββ useComments.ts
β βββ ...30+ more
βββ services/
β βββ userService.ts
β βββ postService.ts
β βββ ...20+ more
βββ types/
βββ user.ts
βββ post.ts
βββ ...20+ more
β Feature-Based (Scales Well)
src/
βββ features/
β βββ users/
β β βββ components/
β β β βββ UserList.tsx
β β β βββ UserCard.tsx
β β β βββ UserForm.tsx
β β βββ hooks/
β β β βββ useUsers.ts
β β βββ api/
β β β βββ userService.ts
β β βββ types/
β β β βββ user.ts
β β βββ index.ts
β βββ posts/
β β βββ components/
β β βββ hooks/
β β βββ api/
β β βββ types/
β βββ comments/
β βββ ...
βββ shared/
βββ components/
βββ hooks/
βββ utils/
Complete Feature-Based Architecture
src/
βββ features/ # Feature modules
β βββ auth/ # Authentication feature
β β βββ components/
β β β βββ LoginForm.tsx
β β β βββ RegisterForm.tsx
β β β βββ ProtectedRoute.tsx
β β βββ hooks/
β β β βββ useAuth.ts
β β β βββ useLogin.ts
β β βββ api/
β β β βββ authService.ts
β β βββ store/ # Feature-specific state
β β β βββ authStore.ts
β β βββ types/
β β β βββ auth.types.ts
β β βββ utils/
β β β βββ tokenManager.ts
β β βββ index.ts # Public API
β β
β βββ users/ # Users feature
β β βββ components/
β β β βββ UserList/
β β β β βββ UserList.tsx
β β β β βββ UserList.test.tsx
β β β β βββ UserList.module.css
β β β βββ UserCard/
β β β βββ UserProfile/
β β βββ hooks/
β β β βββ useUsers.ts
β β β βββ useUser.ts
β β β βββ useUpdateUser.ts
β β βββ api/
β β β βββ userService.ts
β β βββ types/
β β β βββ user.types.ts
β β βββ index.ts
β β
β βββ posts/
β βββ comments/
β βββ dashboard/
β
βββ shared/ # Shared across features
β βββ components/ # Reusable UI components
β β βββ Button/
β β βββ Input/
β β βββ Card/
β β βββ Modal/
β βββ hooks/ # Reusable hooks
β β βββ useLocalStorage.ts
β β βββ useDebounce.ts
β β βββ useMediaQuery.ts
β βββ utils/ # Utility functions
β β βββ formatters.ts
β β βββ validators.ts
β β βββ helpers.ts
β βββ types/ # Shared types
β β βββ common.types.ts
β βββ constants/
β βββ config.ts
β
βββ lib/ # Third-party setup
β βββ react-query.ts # React Query config
β βββ axios.ts # Axios instance
β βββ i18n.ts # Internationalization
β
βββ pages/ # Route components (or routes/)
β βββ HomePage.tsx
β βββ UsersPage.tsx
β βββ UserDetailPage.tsx
β βββ NotFoundPage.tsx
β
βββ layouts/ # Layout components
β βββ MainLayout.tsx
β βββ AuthLayout.tsx
β βββ DashboardLayout.tsx
β
βββ styles/ # Global styles
β βββ globals.css
β βββ variables.css
β βββ theme.css
β
βββ App.tsx # Root component
βββ main.tsx # Entry point
βββ vite-env.d.ts # Vite types
β Benefits of Feature-Based Structure
- Scalability: Add new features without cluttering existing folders
- Maintainability: All related code lives together
- Team collaboration: Multiple devs can work on different features
- Code ownership: Clear boundaries for feature teams
- Easier refactoring: Feature can be moved or removed as a unit
- Better imports: Feature's public API through index.ts
Feature Module Example
// features/users/index.ts - Public API
export { UserList } from './components/UserList';
export { UserCard } from './components/UserCard';
export { useUsers, useUser } from './hooks';
export type { User, UserFilters } from './types/user.types';
// Other files import from the feature
import { UserList, useUsers, User } from '@/features/users';
// NOT from internal paths
// β DON'T: import { UserList } from '@/features/users/components/UserList';
Shared vs Feature-Specific
Deciding what goes in shared/ vs features/:
| Put in shared/ | Put in feature/ |
|---|---|
| Used by 3+ features | Used by only 1-2 features |
| Generic UI components (Button, Input) | Domain-specific components (UserCard) |
| Generic utilities (formatDate, debounce) | Domain logic (calculateUserScore) |
| No business logic | Contains business logic |
| Could be open-sourced | Specific to this app |
β οΈ Avoid Premature Sharing
When creating a new component, start in the feature folder. Move it to shared/ only when you actually need it in a second place. The "Rule of Three" applies: extract to shared when you need it the third time.
π§© Component Organization Patterns
Not all components are created equal. Understanding different component types and their roles helps you organize them effectively and make better architectural decisions.
The Component Hierarchy
1. Page Components (Route Components)
Top-level components that correspond to routes. They orchestrate feature components and manage page-level concerns.
// pages/UsersPage.tsx
import { UserList } from '@/features/users';
import { PageHeader } from '@/shared/components';
import { MainLayout } from '@/layouts';
export function UsersPage() {
return (
<MainLayout>
<PageHeader
title="Users"
description="Manage all users in your application"
/>
<UserList />
</MainLayout>
);
}
// Characteristics:
// - Maps to a route
// - Minimal logic (mostly composition)
// - Uses layouts and features
// - Handles page-level data fetching
// - Sets page metadata (title, etc.)
2. Container Components (Smart Components)
Manage state, data fetching, and business logic. They connect to stores, hooks, and APIs, then pass data down to presentational components.
// features/users/components/UserList/UserListContainer.tsx
import { useUsers } from '../../hooks/useUsers';
import { UserListView } from './UserListView';
export function UserListContainer() {
const {
users,
isLoading,
error,
refetch
} = useUsers();
const [filters, setFilters] = useState<UserFilters>({});
const handleFilterChange = (newFilters: UserFilters) => {
setFilters(newFilters);
};
const filteredUsers = useMemo(() => {
return users?.filter(user => {
// Filtering logic
return true;
});
}, [users, filters]);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
return (
<UserListView
users={filteredUsers}
filters={filters}
onFilterChange={handleFilterChange}
/>
);
}
// Characteristics:
// - Manages state and side effects
// - Connects to data sources
// - Contains business logic
// - Handles loading and error states
// - Passes data to presentational components
3. Presentational Components (Dumb Components)
Focus purely on rendering UI. They receive data through props and notify parents of user interactions through callback props.
// features/users/components/UserList/UserListView.tsx
interface UserListViewProps {
users: User[];
filters: UserFilters;
onFilterChange: (filters: UserFilters) => void;
}
export function UserListView({
users,
filters,
onFilterChange
}: UserListViewProps) {
return (
<div className="user-list">
<UserFilters
filters={filters}
onChange={onFilterChange}
/>
<div className="user-grid">
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
</div>
);
}
// Characteristics:
// - Pure presentation logic
// - No state or side effects
// - Easy to test
// - Reusable with different data
// - Can be used in Storybook
4. UI Components (Shared Components)
Generic, reusable components that have no knowledge of business logic. These live in the shared/components/ folder and can be used across features.
// shared/components/Button/Button.tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
loading?: boolean;
children: React.ReactNode;
}
export function Button({
variant = 'primary',
size = 'medium',
loading = false,
disabled,
children,
className = '',
...props
}: ButtonProps) {
const baseClasses = 'btn';
const variantClass = `btn-${variant}`;
const sizeClass = `btn-${size}`;
return (
<button
className={`${baseClasses} ${variantClass} ${sizeClass} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading ? <Spinner size="small" /> : children}
</button>
);
}
// Characteristics:
// - Generic and reusable
// - No business logic
// - Highly configurable through props
// - Can be documented in Storybook
// - Could be published as a library
π‘ Component Organization Summary
| Component Type | Responsibility | Location |
|---|---|---|
| Page | Route orchestration | pages/ |
| Layout | Shared page structure | layouts/ |
| Feature | Domain-specific features | features/{feature}/components/ |
| Container | State & data management | features/{feature}/components/ |
| Presentational | Pure UI rendering | features/{feature}/components/ |
| Shared UI | Generic reusable UI | shared/components/ |
π Separation of Concerns
Separation of concerns is about organizing code so that each piece has a clear, focused responsibility. In React applications, this means separating presentation, business logic, and data fetching into distinct layers.
The Three Layers
1. Presentation Layer (Components)
Handles only rendering and user interactions. No business logic or data fetching.
// β
GOOD - Pure presentation
function ProductCard({ product, onAddToCart }: ProductCardProps) {
return (
<Card>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<Button onClick={() => onAddToCart(product.id)}>
Add to Cart
</Button>
</Card>
);
}
// β BAD - Mixed concerns
function ProductCard({ productId }: { productId: string }) {
const [product, setProduct] = useState(null);
const [cart, setCart] = useState([]);
useEffect(() => {
// Data fetching in component
fetch(`/api/products/${productId}`)
.then(res => res.json())
.then(setProduct);
}, [productId]);
const addToCart = () => {
// Business logic in component
if (cart.length >= 10) {
alert('Cart is full!');
return;
}
const tax = product.price * 0.08;
const total = product.price + tax;
setCart([...cart, { ...product, total }]);
// Data mutation in component
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId })
});
};
return (/* ... */);
}
2. Business Logic Layer (Custom Hooks)
Encapsulates business rules, calculations, and state management logic.
// hooks/useCart.ts
export function useCart() {
const [cart, setCart] = useState<CartItem[]>([]);
const addToCart = useCallback((product: Product) => {
// Business rule: Max 10 items
if (cart.length >= 10) {
throw new Error('Cart is full. Maximum 10 items allowed.');
}
// Business logic: Calculate totals
const tax = calculateTax(product.price);
const total = product.price + tax;
const cartItem: CartItem = {
...product,
tax,
total,
addedAt: new Date()
};
setCart(prev => [...prev, cartItem]);
return cartItem;
}, [cart.length]);
const removeFromCart = useCallback((itemId: string) => {
setCart(prev => prev.filter(item => item.id !== itemId));
}, []);
const cartTotal = useMemo(() => {
return cart.reduce((sum, item) => sum + item.total, 0);
}, [cart]);
const itemCount = cart.length;
const isFull = itemCount >= 10;
return {
cart,
cartTotal,
itemCount,
isFull,
addToCart,
removeFromCart
};
}
// Utility function for business logic
function calculateTax(price: number): number {
const TAX_RATE = 0.08;
return price * TAX_RATE;
}
3. Data Access Layer (Services)
Handles all communication with external systems (APIs, localStorage, etc.).
// services/cartService.ts
import { apiClient } from '@/lib/axios';
export const cartService = {
async getCart(userId: string): Promise<CartItem[]> {
const response = await apiClient.get(`/users/${userId}/cart`);
return response.data;
},
async addToCart(userId: string, productId: string): Promise<CartItem> {
const response = await apiClient.post(`/users/${userId}/cart`, {
productId
});
return response.data;
},
async removeFromCart(userId: string, itemId: string): Promise<void> {
await apiClient.delete(`/users/${userId}/cart/${itemId}`);
},
async clearCart(userId: string): Promise<void> {
await apiClient.delete(`/users/${userId}/cart`);
}
};
Putting It All Together
// Component uses hook (business logic)
function ProductPage({ productId }: { productId: string }) {
const { product } = useProduct(productId);
const { addToCart, isFull } = useCart();
const handleAddToCart = async () => {
try {
await addToCart(product);
toast.success('Added to cart!');
} catch (error) {
toast.error(error.message);
}
};
return (
<ProductCard
product={product}
onAddToCart={handleAddToCart}
disabled={isFull}
/>
);
}
// Hook uses service (data access)
function useCart() {
const { user } = useAuth();
const queryClient = useQueryClient();
const { data: cart = [] } = useQuery({
queryKey: ['cart', user?.id],
queryFn: () => cartService.getCart(user!.id),
enabled: !!user
});
const addMutation = useMutation({
mutationFn: (product: Product) => {
// Business logic first
if (cart.length >= 10) {
throw new Error('Cart is full');
}
// Then data access
return cartService.addToCart(user!.id, product.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
}
});
const addToCart = async (product: Product) => {
return addMutation.mutateAsync(product);
};
return {
cart,
addToCart,
isFull: cart.length >= 10
};
}
// Service handles API calls
const cartService = {
async addToCart(userId: string, productId: string) {
const response = await apiClient.post(`/users/${userId}/cart`, {
productId
});
return response.data;
}
};
β Benefits of Separation
- Testability: Each layer can be tested independently
- Reusability: Business logic can be used in multiple components
- Maintainability: Changes are localized to one layer
- Readability: Each file has a clear, focused purpose
- Flexibility: Easy to swap implementations (e.g., change API)
πͺ Custom Hooks for Business Logic
Custom hooks are the perfect place to encapsulate business logic in React applications. They keep components clean and focused on rendering while making logic reusable and testable.
When to Create a Custom Hook
π‘ Create a Custom Hook When...
- Logic is used in multiple components
- Component has complex state management
- You want to test business logic independently
- Logic involves multiple React hooks
- You need to share stateful logic between components
Hook Patterns and Examples
1. Data Fetching Hooks
// hooks/useUser.ts
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => userService.getById(userId),
staleTime: 1000 * 60 * 5
});
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId);
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
}
2. Form Handling Hooks
// hooks/useForm.ts
interface UseFormOptions<T> {
initialValues: T;
onSubmit: (values: T) => void | Promise<void>;
validate?: (values: T) => Record<string, string>;
}
export function useForm<T extends Record<string, any>>({
initialValues,
onSubmit,
validate
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (name: keyof T, value: any) => {
setValues(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name as string]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name as string];
return newErrors;
});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate
if (validate) {
const validationErrors = validate(values);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
}
// Submit
setIsSubmitting(true);
try {
await onSubmit(values);
setValues(initialValues); // Reset form
} catch (error) {
setErrors({ submit: error.message });
} finally {
setIsSubmitting(false);
}
};
const reset = () => {
setValues(initialValues);
setErrors({});
};
return {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
reset
};
}
// Usage
function LoginForm() {
const { values, errors, handleChange, handleSubmit, isSubmitting } = useForm({
initialValues: { email: '', password: '' },
onSubmit: async (values) => {
await authService.login(values);
},
validate: (values) => {
const errors: Record<string, string> = {};
if (!values.email) errors.email = 'Email is required';
if (!values.password) errors.password = 'Password is required';
return errors;
}
});
return (
<form onSubmit={handleSubmit}>
<Input
value={values.email}
onChange={(e) => handleChange('email', e.target.value)}
error={errors.email}
/>
<Button type="submit" loading={isSubmitting}>
Login
</Button>
</form>
);
}
3. Business Logic Hooks
// hooks/useShoppingCart.ts
export function useShoppingCart() {
const [items, setItems] = useState<CartItem[]>([]);
const addItem = useCallback((product: Product, quantity = 1) => {
setItems(prev => {
const existing = prev.find(item => item.product.id === product.id);
if (existing) {
// Update quantity if item exists
return prev.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
);
}
// Add new item
return [...prev, { product, quantity }];
});
}, []);
const removeItem = useCallback((productId: string) => {
setItems(prev => prev.filter(item => item.product.id !== productId));
}, []);
const updateQuantity = useCallback((productId: string, quantity: number) => {
if (quantity <= 0) {
removeItem(productId);
return;
}
setItems(prev =>
prev.map(item =>
item.product.id === productId
? { ...item, quantity }
: item
)
);
}, [removeItem]);
const clearCart = useCallback(() => {
setItems([]);
}, []);
// Computed values
const subtotal = useMemo(() => {
return items.reduce((sum, item) => {
return sum + (item.product.price * item.quantity);
}, 0);
}, [items]);
const tax = useMemo(() => subtotal * 0.08, [subtotal]);
const total = subtotal + tax;
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
return {
items,
itemCount,
subtotal,
tax,
total,
addItem,
removeItem,
updateQuantity,
clearCart
};
}
4. Utility Hooks
// hooks/useDebounce.ts
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
// hooks/useLocalStorage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue] as const;
}
// hooks/useMediaQuery.ts
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handleChange = (e: MediaQueryListEvent) => {
setMatches(e.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [query]);
return matches;
}
// Usage
function SearchBar() {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 500);
const [theme, setTheme] = useLocalStorage('theme', 'light');
const isMobile = useMediaQuery('(max-width: 768px)');
// Use debouncedSearch for API calls
const { data } = useQuery({
queryKey: ['search', debouncedSearch],
queryFn: () => searchApi(debouncedSearch),
enabled: debouncedSearch.length > 0
});
return (/* ... */);
}
β Custom Hook Best Practices
- Naming: Always start with "use" (useCart, useAuth, useDebounce)
- Single Responsibility: Each hook should do one thing well
- Return Object: Return objects for flexibility, not arrays (unless 2 items max)
- Memoization: Use useCallback and useMemo for expensive operations
- Dependencies: Always list all dependencies correctly
- Type Safety: Use TypeScript generics for reusable hooks
π Service Layer Pattern
The service layer is a dedicated abstraction for all external communication. It centralizes API calls, error handling, and data transformation, making your application easier to maintain and test.
Why Use a Service Layer?
| Benefit | Description |
|---|---|
| Centralization | All API logic in one place |
| Reusability | Same endpoints used across features |
| Testability | Easy to mock for tests |
| Type Safety | Typed inputs and outputs |
| Error Handling | Consistent error handling |
Service Structure
// lib/axios.ts - Configure axios instance
import axios from 'axios';
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// Request interceptor - Add auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - Handle errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Redirect to login
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Creating Services
// services/userService.ts
import { apiClient } from '@/lib/axios';
import type { User, CreateUserDto, UpdateUserDto } from '@/types';
export const userService = {
/**
* Get all users
*/
async getAll(): Promise<User[]> {
const response = await apiClient.get<User[]>('/users');
return response.data;
},
/**
* Get user by ID
*/
async getById(id: string): Promise<User> {
const response = await apiClient.get<User>(`/users/${id}`);
return response.data;
},
/**
* Create new user
*/
async create(data: CreateUserDto): Promise<User> {
const response = await apiClient.post<User>('/users', data);
return response.data;
},
/**
* Update existing user
*/
async update(id: string, data: UpdateUserDto): Promise<User> {
const response = await apiClient.patch<User>(`/users/${id}`, data);
return response.data;
},
/**
* Delete user
*/
async delete(id: string): Promise<void> {
await apiClient.delete(`/users/${id}`);
},
/**
* Search users by query
*/
async search(query: string): Promise<User[]> {
const response = await apiClient.get<User[]>('/users/search', {
params: { q: query }
});
return response.data;
}
};
Advanced Service Patterns
// services/baseService.ts - Generic base service
export class BaseService<T> {
constructor(private endpoint: string) {}
async getAll(): Promise<T[]> {
const response = await apiClient.get<T[]>(this.endpoint);
return response.data;
}
async getById(id: string): Promise<T> {
const response = await apiClient.get<T>(`${this.endpoint}/${id}`);
return response.data;
}
async create(data: Partial<T>): Promise<T> {
const response = await apiClient.post<T>(this.endpoint, data);
return response.data;
}
async update(id: string, data: Partial<T>): Promise<T> {
const response = await apiClient.patch<T>(`${this.endpoint}/${id}`, data);
return response.data;
}
async delete(id: string): Promise<void> {
await apiClient.delete(`${this.endpoint}/${id}`);
}
}
// services/userService.ts - Extend base service
class UserService extends BaseService<User> {
constructor() {
super('/users');
}
// Add user-specific methods
async getProfile(): Promise<User> {
const response = await apiClient.get<User>('/users/profile');
return response.data;
}
async updateProfile(data: UpdateProfileDto): Promise<User> {
const response = await apiClient.patch<User>('/users/profile', data);
return response.data;
}
async changePassword(data: ChangePasswordDto): Promise<void> {
await apiClient.post('/users/change-password', data);
}
}
export const userService = new UserService();
Service with React Query
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userService } from '@/services/userService';
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: string) => [...userKeys.lists(), { filters }] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
export function useUsers() {
return useQuery({
queryKey: userKeys.lists(),
queryFn: userService.getAll
});
}
export function useUser(id: string) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => userService.getById(id)
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: userService.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
}
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateUserDto }) =>
userService.update(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
}
});
}
β οΈ Service Layer Anti-Patterns
- Business Logic in Services: Services should only handle data access, not business rules
- Direct State Updates: Services shouldn't update React state directly
- Component Dependencies: Services shouldn't import components or hooks
- Mixed Responsibilities: Keep services focused on API communication
π Type Organization
Proper TypeScript type organization makes your codebase more maintainable and helps developers understand data structures quickly.
Type Organization Strategies
1. Feature-Based Types
// features/users/types/user.types.ts
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateUserDto {
email: string;
name: string;
password: string;
}
export interface UpdateUserDto {
name?: string;
avatar?: string;
}
export interface UserFilters {
search?: string;
role?: UserRole;
status?: UserStatus;
}
export type UserRole = 'admin' | 'user' | 'moderator';
export type UserStatus = 'active' | 'inactive' | 'suspended';
2. Shared/Common Types
// shared/types/common.types.ts
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export interface ApiError {
message: string;
code: string;
statusCode: number;
details?: Record<string, any>;
}
export type Nullable<T> = T | null;
export type Optional<T> = T | undefined;
export type ID = string | number;
3. Utility Types
// shared/types/utils.types.ts
// Make all properties optional recursively
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Make all properties required recursively
export type DeepRequired<T> = {
[P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};
// Extract keys of type
export type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T];
// Async function return type
export type AsyncReturnType<T extends (...args: any) => Promise<any>> =
T extends (...args: any) => Promise<infer R> ? R : any;
β Type Organization Best Practices
- Naming: Use descriptive names (UserDto, not UD)
- DTOs: Create separate types for API requests/responses
- Consistency: Use same naming patterns across types
- Documentation: Add JSDoc comments for complex types
- Exports: Export through index.ts files
- Avoid Any: Never use 'any', use 'unknown' instead
π¨ Error Handling Architecture
Consistent, well-architected error handling improves user experience and makes debugging easier. A good error handling strategy catches errors at the right level and provides meaningful feedback.
Error Handling Layers
1. Custom Error Classes
// shared/errors/AppError.ts
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number,
public details?: any
) {
super(message);
this.name = 'AppError';
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: Record<string, string>) {
super(message, 'VALIDATION_ERROR', 400, details);
this.name = 'ValidationError';
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Authentication required') {
super(message, 'AUTH_ERROR', 401);
this.name = 'AuthenticationError';
}
}
export class AuthorizationError extends AppError {
constructor(message = 'Insufficient permissions') {
super(message, 'AUTHORIZATION_ERROR', 403);
this.name = 'AuthorizationError';
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 'NOT_FOUND', 404);
this.name = 'NotFoundError';
}
}
2. Error Handling in Services
// lib/axios.ts
import { AppError, ValidationError, AuthenticationError } from '@/shared/errors';
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data;
switch (status) {
case 400:
throw new ValidationError(
data?.message || 'Validation failed',
data?.errors
);
case 401:
throw new AuthenticationError(data?.message);
case 403:
throw new AuthorizationError(data?.message);
case 404:
throw new NotFoundError(data?.resource || 'Resource');
default:
throw new AppError(
data?.message || 'An error occurred',
'UNKNOWN_ERROR',
status || 500,
data
);
}
}
throw error;
}
);
3. Error Boundaries
// shared/components/ErrorBoundary.tsx
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{ error: Error; reset: () => void }>;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
// Log to error reporting service
// errorReportingService.log(error, errorInfo);
}
reset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
const FallbackComponent = this.props.fallback || DefaultErrorFallback;
return <FallbackComponent error={this.state.error!} reset={this.reset} />;
}
return this.props.children;
}
}
// Default error fallback
function DefaultErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
4. Error Handling in Components
// Component with error handling
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useUser(userId);
const updateMutation = useUpdateUser();
const handleUpdate = async (data: UpdateUserDto) => {
try {
await updateMutation.mutateAsync({ id: userId, data });
toast.success('Profile updated successfully!');
} catch (error) {
if (error instanceof ValidationError) {
// Show field-specific errors
Object.entries(error.details || {}).forEach(([field, message]) => {
toast.error(`${field}: ${message}`);
});
} else if (error instanceof AppError) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred');
}
}
};
if (isLoading) return <Spinner />;
if (error) {
if (error instanceof NotFoundError) {
return <NotFound message="User not found" />;
}
if (error instanceof AuthorizationError) {
return <Unauthorized />;
}
return <ErrorMessage error={error} />;
}
return <UserForm user={data} onSubmit={handleUpdate} />;
}
π‘ Error Handling Best Practices
- Fail Fast: Catch errors as early as possible
- Be Specific: Use custom error classes for different scenarios
- User-Friendly: Show helpful messages, not technical jargon
- Logging: Log errors to a service for debugging
- Recovery: Provide ways to recover (retry button, reset)
- Boundaries: Use Error Boundaries for unexpected errors
ποΈ Hands-On Exercises
Exercise 1: Refactor a Monolithic Component
Goal: Take a component that's doing too much and refactor it using proper architectural patterns.
Starting Point:
// β This component has multiple problems
function ProductDashboard() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [cart, setCart] = useState([]);
const [search, setSearch] = useState('');
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
});
}, []);
const addToCart = (product) => {
const tax = product.price * 0.08;
const total = product.price + tax;
setCart([...cart, { ...product, tax, total }]);
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId: product.id })
});
};
const filteredProducts = products.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{loading ? 'Loading...' : (
filteredProducts.map(product => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => addToCart(product)}>
Add to Cart
</button>
</div>
))
)}
</div>
);
}
Your Task:
- Create a service layer for API calls (
productService.ts,cartService.ts) - Extract business logic into custom hooks (
useProducts,useCart) - Break into smaller components (
ProductList,ProductCard,SearchBar) - Add proper TypeScript types
- Implement error handling
- Use React Query for data fetching
π‘ Hint #1: Architecture Structure
// Structure to aim for:
// services/productService.ts
// services/cartService.ts
// hooks/useProducts.ts
// hooks/useCart.ts
// components/ProductDashboard.tsx (orchestrator)
// components/ProductList.tsx (presentational)
// components/ProductCard.tsx (presentational)
// components/SearchBar.tsx (presentational)
π‘ Hint #2: Service Layer
// services/productService.ts
export const productService = {
async getAll(): Promise<Product[]> {
const response = await apiClient.get('/api/products');
return response.data;
}
};
// services/cartService.ts
export const cartService = {
async addItem(productId: string): Promise<CartItem> {
const response = await apiClient.post('/api/cart', { productId });
return response.data;
}
};
β Solution Outline
Your refactored solution should have:
- Services: Clean API abstractions
- Hooks: useProducts (with React Query), useCart (with business logic)
- Components: ProductDashboard (orchestrator), ProductList (receives data), ProductCard (pure UI), SearchBar (controlled input)
- Types: Product, CartItem, AddToCartDto interfaces
- Error Handling: Try-catch in handlers, Error Boundary around dashboard
Exercise 2: Design a Feature Module
Goal: Create a complete feature module following best practices for folder structure and organization.
Scenario: You're building a "Comments" feature for a blog application. Users should be able to:
- View comments on a post
- Add new comments
- Edit their own comments
- Delete their own comments
- Reply to comments (nested)
Your Task:
- Design the folder structure for
features/comments/ - Define TypeScript types and interfaces
- Create the service layer
- Design custom hooks
- Plan component hierarchy
- Create the public API (index.ts)
π‘ Hint: Folder Structure
features/comments/
βββ components/
β βββ CommentList/
β β βββ CommentList.tsx
β β βββ CommentList.test.tsx
β β βββ CommentList.module.css
β βββ CommentItem/
β βββ CommentForm/
β βββ CommentReply/
βββ hooks/
β βββ useComments.ts
β βββ useAddComment.ts
β βββ useUpdateComment.ts
β βββ useDeleteComment.ts
βββ api/
β βββ commentService.ts
βββ types/
β βββ comment.types.ts
βββ utils/
β βββ commentHelpers.ts
βββ index.ts
β Solution Checklist
- β Types defined (Comment, CreateCommentDto, UpdateCommentDto)
- β Service with all CRUD operations
- β Custom hooks using React Query
- β Component hierarchy planned (List > Item > Form/Reply)
- β Error handling strategy defined
- β Public API exports only what's needed
- β Optimistic updates for better UX
Exercise 3: Implement Error Handling Strategy
Goal: Add comprehensive error handling to an existing feature.
Scenario: You have a user authentication feature that currently has no error handling. Add proper error handling at all layers.
Requirements:
- Create custom error classes (AuthError, ValidationError, NetworkError)
- Add error interceptor to axios
- Implement Error Boundary for the auth pages
- Add try-catch blocks in components
- Show user-friendly error messages
- Log errors to console (simulate error tracking service)
π‘ Hint: Error Class Hierarchy
class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number
) {
super(message);
}
}
class AuthError extends AppError {
constructor(message: string) {
super(message, 'AUTH_ERROR', 401);
}
}
// Handle in component:
try {
await login(credentials);
} catch (error) {
if (error instanceof AuthError) {
setError('Invalid email or password');
} else if (error instanceof NetworkError) {
setError('Connection failed. Please try again.');
} else {
setError('An unexpected error occurred');
}
}
π§ Knowledge Check Quiz
Question 1: Separation of Concerns
What are the three main layers in a well-architected React application, and what is each responsible for?
Show Answer
Answer:
- Presentation Layer (Components): Handles rendering UI and user interactions. Components should be focused on display logic only, not business rules or data fetching.
- Business Logic Layer (Custom Hooks): Contains application logic, calculations, validations, and state management. This is where you implement business rules and domain logic.
- Data Access Layer (Services): Handles all external communication like API calls, localStorage, or other data sources. Services provide a clean abstraction for data operations.
Question 2: Component Types
What's the difference between Container Components and Presentational Components? When should you use each?
Show Answer
Answer:
Container Components (Smart Components):
- Manage state and side effects
- Connect to data sources (hooks, context, stores)
- Contain business logic
- Pass data down to presentational components
- Use when: You need to fetch data, manage state, or implement business logic
Presentational Components (Dumb Components):
- Receive data only through props
- Focus purely on UI rendering
- No state or side effects
- Highly reusable and testable
- Use when: You need a reusable UI component that just displays data
Question 3: Folder Structure
Why is a feature-based folder structure better than organizing by file type (components/, hooks/, services/)? Give at least three reasons.
Show Answer
Answer:
- Scalability: As the app grows, feature folders don't become cluttered. Each feature is self-contained.
- Maintainability: All related code lives together, making it easier to understand and modify a feature.
- Team Collaboration: Different teams can work on different features without conflicts.
- Code Ownership: Clear boundaries make it obvious who owns what code.
- Easier Refactoring: Features can be moved, removed, or extracted as complete units.
- Better Imports: Features export a public API through index.ts, hiding implementation details.
Question 4: Service Layer
What should go in a service layer, and what should NOT go there?
Show Answer
Should go in services:
- API calls (GET, POST, PUT, DELETE)
- Request/response transformation (if needed)
- HTTP configuration (headers, auth tokens)
- Basic error throwing
Should NOT go in services:
- Business logic (calculations, validations)
- React state updates
- Component dependencies
- UI logic or rendering
- Direct access to React hooks
Principle: Services should be pure data access - they communicate with external systems and return/send data, nothing more.
Question 5: Custom Hooks
When should you create a custom hook? Give an example of logic that belongs in a custom hook vs logic that should stay in a component.
Show Answer
Create a custom hook when:
- Logic is used in multiple components
- Logic involves multiple React hooks
- Logic contains business rules
- You want to test logic independently
- Logic manages complex state
Example - Belongs in Custom Hook:
// useShoppingCart.ts
- Calculate cart totals
- Add/remove items logic
- Cart state management
- Business rules (max items, etc.)
Example - Stay in Component:
// Component
- Button click handlers that just call hooks
- Simple UI state (modal open/closed)
- Navigation after actions
- Direct prop transformations for rendering
Question 6: Error Handling
What are the different layers where you should handle errors in a React application? What type of errors should be handled at each layer?
Show Answer
Answer:
- API Layer (Axios Interceptors):
- HTTP status code errors
- Network errors
- Transform to custom error classes
- Handle auth token refresh
- Service Layer:
- Throw custom errors
- Add context to errors
- No catching (let them bubble up)
- Hook Layer:
- React Query onError callbacks
- Error state management
- Some errors may be caught here
- Component Layer:
- Show error UI to users
- Try-catch for async actions
- User-friendly error messages
- Error Boundaries for unexpected errors
Question 7: Type Organization
Where should you put type definitions in a feature-based architecture? Give examples of feature-specific vs shared types.
Show Answer
Type Locations:
- Feature-specific types:
features/{feature}/types/ - Shared types:
shared/types/
Feature-Specific Types (features/users/types/):
- User
- CreateUserDto
- UpdateUserDto
- UserFilters
- UserRole (enum)
Shared Types (shared/types/):
- ApiResponse<T>
- PaginatedResponse<T>
- ApiError
- ID (type alias)
- Nullable<T>
- Common utility types
Question 8: Architecture Principles
Explain the SOLID principle that's most important in React: Single Responsibility Principle. How does it apply to React components?
Show Answer
Single Responsibility Principle:
Each component, function, or module should have one reason to change - one well-defined responsibility.
In React Components:
- A component should do ONE thing (display user data, OR fetch it, OR validate it - not all three)
- If you can't describe what a component does in one sentence, it's doing too much
- Changes to business logic shouldn't require changes to UI components
- Changes to API structure shouldn't require changes to components
Example:
- β BAD:
UserProfilefetches data, validates form, handles submission, and renders UI - β
GOOD:
UserProfilejust orchestrates. Data fetching is inuseUserhook, form logic inuseForm, validation invalidators, and UI in presentational components
π Lesson Summary
π Congratulations on Completing Lesson 8.5!
You've just completed the final lesson of Module 8! You now have a comprehensive understanding of React architecture patterns that will serve you throughout your career as a professional React developer.
What You've Learned
- β Architecture Principles: Separation of Concerns, DRY, KISS, Single Responsibility, and Composition Over Inheritance
- β Feature-Based Structure: How to organize large codebases by feature instead of file type
- β Component Organization: Page, Layout, Container, Presentational, and Shared component patterns
- β Separation of Concerns: Dividing code into Presentation, Business Logic, and Data Access layers
- β Custom Hooks: Extracting business logic into reusable, testable hooks
- β Service Layer: Creating a clean abstraction for API communication
- β Type Organization: Structuring TypeScript types for maintainability
- β Error Handling: Implementing comprehensive error handling at every layer
π― Key Architectural Takeaways
- Components should be simple: If a component is hard to understand or test, it's doing too much. Break it down.
- Business logic belongs in hooks: Not in components, not in services - custom hooks are the perfect place.
- Services only handle data access: No business logic, no state management - just API calls.
- Feature-based structure scales: Organize by feature, not by file type, especially as your app grows.
- Types should be close to usage: Feature-specific types in feature folders, shared types in shared folder.
- Error handling is multi-layered: Handle errors at the appropriate level - API, service, hook, or component.
- Test your architecture: If something is hard to test, it needs better separation of concerns.
- Start simple, refactor as needed: Don't over-engineer early. The "Rule of Three" applies - abstract when you need it the third time.
π Module 8 Complete!
You've now completed all five lessons in Module 8: State Management and Architecture:
- β Lesson 8.1: State Management Overview
- β Lesson 8.2: Zustand Basics
- β Lesson 8.3: Redux Toolkit
- β Lesson 8.4: React Query (TanStack Query)
- β Lesson 8.5: Architecture Best Practices
You've learned how to build production-ready, scalable React applications!
π What's Next?
You're ready to apply everything you've learned in Module 8!
Immediate Next Step:
Module 8 Project: Social Media Feed
Build a complete social media feed application that demonstrates:
- Feature-based folder structure
- React Query for data fetching
- Zustand or Redux Toolkit for client state
- Proper component organization
- Custom hooks for business logic
- Service layer for API calls
- Comprehensive error handling
- TypeScript throughout
Coming Up Next: Module 9 - Testing React Applications
After completing your Module 8 project, you'll learn:
- Testing fundamentals and the testing pyramid
- React Testing Library
- Testing user interactions and async code
- Integration and E2E testing
- Testing best practices
π‘ Applying What You've Learned
In Your Next Project:
- Start with structure: Set up feature folders before writing code
- Identify layers: Decide what goes in services, hooks, and components
- Type everything: Define TypeScript interfaces before implementing
- Separate concerns: Keep components focused on UI
- Plan error handling: Set up error classes and boundaries early
- Refactor regularly: As patterns emerge, extract and organize