Week 4: Component Architecture
State Management and Backend Patterns
Complete this reading before Week 5. Estimated time: 50-65 minutes.
Introduction
Last week we explored layered architecture and how to organize code into distinct layers with clear responsibilities. This week, we go deeper into two critical areas: React component architecture and state management on the frontend, and backend service patterns that support scalable API development.
Component architecture is about more than just organizing files—it’s about designing systems of reusable, composable pieces that work together effectively. Similarly, backend patterns help you structure your API code to handle growing complexity while remaining maintainable.
React Component Architecture
Thinking in Components
React’s component model encourages you to think about UIs as trees of components, where each component:
- Has a single responsibility
- Can be composed with other components
- Manages its own internal state (when needed)
- Communicates through props and callbacks
┌─────────────────────────────────────────────────────────────────┐
│ App │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Header │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ Logo │ │ Navigation │ │ UserMenu │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ MainContent │ │
│ │ ┌────────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Sidebar │ │ TaskList │ │ │
│ │ │ ┌──────────────┐ │ │ ┌──────────────────────┐ │ │ │
│ │ │ │ FilterPanel │ │ │ │ TaskCard │ │ │ │
│ │ │ └──────────────┘ │ │ ├──────────────────────┤ │ │ │
│ │ │ ┌──────────────┐ │ │ │ TaskCard │ │ │ │
│ │ │ │ QuickStats │ │ │ ├──────────────────────┤ │ │ │
│ │ │ └──────────────┘ │ │ │ TaskCard │ │ │ │
│ │ └────────────────────┘ │ └──────────────────────┘ │ │ │
│ │ └────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Component Design Principles
1. Single Responsibility
Each component should do one thing well:
// Bad: Component doing too much
function TaskManager() {
// Fetching data
// Managing form state
// Handling validation
// Rendering list
// Rendering form
// Handling filters
// All in one component...
}
// Good: Separated responsibilities
function TasksPage() {
return (
<div className="tasks-page">
<TaskFilters />
<TaskList />
<TaskForm />
</div>
);
}2. Composition Over Configuration
Prefer composing simple components over configuring complex ones:
// Bad: One component with many configuration props
<Card
title="Task"
showHeader={true}
showFooter={true}
headerActions={['edit', 'delete']}
footerContent={<Button>Save</Button>}
bordered={true}
shadowed={true}
// ... many more props
/>
// Good: Composed from simple components
<Card bordered shadowed>
<Card.Header>
<Card.Title>Task</Card.Title>
<Card.Actions>
<EditButton />
<DeleteButton />
</Card.Actions>
</Card.Header>
<Card.Body>
{/* content */}
</Card.Body>
<Card.Footer>
<Button>Save</Button>
</Card.Footer>
</Card>3. Props Down, Events Up
Data flows down through props; changes propagate up through callbacks:
function TaskList({ tasks, onTaskComplete, onTaskDelete }) {
return (
<ul>
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onComplete={() => onTaskComplete(task.id)}
onDelete={() => onTaskDelete(task.id)}
/>
))}
</ul>
);
}
function TaskItem({ task, onComplete, onDelete }) {
return (
<li>
<span>{task.title}</span>
<button onClick={onComplete}>Complete</button>
<button onClick={onDelete}>Delete</button>
</li>
);
}Component Categories
Presentational Components
Focus purely on rendering UI:
// Button.jsx - Presentational component
function Button({
children,
variant = 'primary',
size = 'medium',
disabled = false,
onClick
}) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
// TaskCard.jsx - Presentational component
function TaskCard({ task, onEdit, onDelete, onToggleComplete }) {
const statusClass = task.completed ? 'completed' :
task.overdue ? 'overdue' : '';
return (
<div className={`task-card ${statusClass}`}>
<div className="task-header">
<input
type="checkbox"
checked={task.completed}
onChange={onToggleComplete}
/>
<h3 className="task-title">{task.title}</h3>
</div>
{task.description && (
<p className="task-description">{task.description}</p>
)}
<div className="task-meta">
{task.dueDate && (
<span className="due-date">
Due: {formatDate(task.dueDate)}
</span>
)}
{task.assignee && (
<span className="assignee">
Assigned to: {task.assignee.name}
</span>
)}
</div>
<div className="task-actions">
<Button variant="secondary" size="small" onClick={onEdit}>
Edit
</Button>
<Button variant="danger" size="small" onClick={onDelete}>
Delete
</Button>
</div>
</div>
);
}Container Components
Manage data and behavior:
// TaskListContainer.jsx
function TaskListContainer({ projectId }) {
const { tasks, loading, error, refresh } = useTasks(projectId);
const { completeTask, deleteTask } = useTaskActions();
const [selectedTask, setSelectedTask] = useState(null);
const handleComplete = async (taskId) => {
await completeTask(taskId);
refresh();
};
const handleDelete = async (taskId) => {
if (confirm('Delete this task?')) {
await deleteTask(taskId);
refresh();
}
};
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} onRetry={refresh} />;
if (tasks.length === 0) return <EmptyState message="No tasks yet" />;
return (
<>
<TaskList
tasks={tasks}
onTaskSelect={setSelectedTask}
onTaskComplete={handleComplete}
onTaskDelete={handleDelete}
/>
{selectedTask && (
<TaskDetailModal
task={selectedTask}
onClose={() => setSelectedTask(null)}
/>
)}
</>
);
}Layout Components
Handle page structure and layout concerns:
// PageLayout.jsx
function PageLayout({ children }) {
return (
<div className="page-layout">
<Header />
<div className="page-content">
<Sidebar />
<main className="main-content">
{children}
</main>
</div>
<Footer />
</div>
);
}
// SplitPane.jsx
function SplitPane({ left, right, leftWidth = '30%' }) {
return (
<div className="split-pane">
<div className="split-left" style={{ width: leftWidth }}>
{left}
</div>
<div className="split-right">
{right}
</div>
</div>
);
}
// Usage
function TasksPage() {
return (
<PageLayout>
<SplitPane
left={<TaskFilters />}
right={<TaskListContainer />}
leftWidth="250px"
/>
</PageLayout>
);
}State Management
Understanding React State
State in React represents data that changes over time. Understanding where state should live is crucial for maintainable applications.
State Placement Guidelines
| State Type | Where to Put It | Example |
|---|---|---|
| UI State | Local component | Modal open/closed, form input values |
| Shared UI State | Nearest common ancestor | Selected tab in sibling components |
| Server Cache | Custom hook or library | Fetched data, loading states |
| Global App State | Context or state library | Current user, theme, permissions |
Local State with useState
For state that doesn’t need to be shared:
function TaskForm({ onSubmit }) {
// Form state is local to this component
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [dueDate, setDueDate] = useState('');
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!title.trim()) {
newErrors.title = 'Title is required';
}
if (dueDate && new Date(dueDate) < new Date()) {
newErrors.dueDate = 'Due date cannot be in the past';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) {
onSubmit({ title, description, dueDate });
// Reset form
setTitle('');
setDescription('');
setDueDate('');
}
};
return (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className={errors.title ? 'error' : ''}
/>
{errors.title && <span className="error-message">{errors.title}</span>}
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="dueDate">Due Date</label>
<input
id="dueDate"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className={errors.dueDate ? 'error' : ''}
/>
{errors.dueDate && <span className="error-message">{errors.dueDate}</span>}
</div>
<button type="submit">Create Task</button>
</form>
);
}Complex Local State with useReducer
When state logic becomes complex, useReducer provides better organization:
// Define action types and reducer
const taskFormReducer = (state, action) => {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: null }
};
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.error }
};
case 'SET_ERRORS':
return { ...state, errors: action.errors };
case 'SET_SUBMITTING':
return { ...state, isSubmitting: action.value };
case 'RESET':
return initialState;
default:
return state;
}
};
const initialState = {
values: { title: '', description: '', dueDate: '' },
errors: {},
isSubmitting: false
};
function TaskForm({ onSubmit }) {
const [state, dispatch] = useReducer(taskFormReducer, initialState);
const handleChange = (field) => (e) => {
dispatch({ type: 'SET_FIELD', field, value: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
// Validate
const errors = validateTaskForm(state.values);
if (Object.keys(errors).length > 0) {
dispatch({ type: 'SET_ERRORS', errors });
return;
}
dispatch({ type: 'SET_SUBMITTING', value: true });
try {
await onSubmit(state.values);
dispatch({ type: 'RESET' });
} finally {
dispatch({ type: 'SET_SUBMITTING', value: false });
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.title}
onChange={handleChange('title')}
disabled={state.isSubmitting}
/>
{state.errors.title && <span>{state.errors.title}</span>}
{/* ... more fields */}
<button disabled={state.isSubmitting}>
{state.isSubmitting ? 'Saving...' : 'Create Task'}
</button>
</form>
);
}Context Best Practices
1. Separate Contexts by Domain
// Bad: One giant context
const AppContext = createContext({
user: null,
theme: 'light',
tasks: [],
notifications: [],
settings: {}
});
// Good: Separate contexts
const AuthContext = createContext(null);
const ThemeContext = createContext(null);
const NotificationContext = createContext(null);2. Optimize Re-renders
// Problem: Any change re-renders all consumers
const TaskContext = createContext(null);
function TaskProvider({ children }) {
const [tasks, setTasks] = useState([]);
const [filter, setFilter] = useState('all');
// This object is recreated every render
const value = { tasks, filter, setTasks, setFilter };
return (
<TaskContext.Provider value={value}>
{children}
</TaskContext.Provider>
);
}
// Solution: Memoize the value and split contexts
function TaskProvider({ children }) {
const [tasks, setTasks] = useState([]);
const [filter, setFilter] = useState('all');
const taskValue = useMemo(() => ({ tasks, setTasks }), [tasks]);
const filterValue = useMemo(() => ({ filter, setFilter }), [filter]);
return (
<TaskStateContext.Provider value={taskValue}>
<TaskFilterContext.Provider value={filterValue}>
{children}
</TaskFilterContext.Provider>
</TaskStateContext.Provider>
);
}3. Provide Default Values for Testing
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {} // No-op default
});
// In tests, components work without provider
// In app, provider overrides defaultsState Management Libraries
For complex applications, dedicated state management libraries offer additional features.
When to Use a Library
| Scenario | Recommendation |
|---|---|
| Small app, simple state | useState + Context |
| Medium app, moderate complexity | Context + useReducer |
| Large app, complex state | Consider Zustand or Redux Toolkit |
| Heavy server state caching | TanStack Query (React Query) |
Example: Zustand (Lightweight Alternative)
// store/taskStore.js
import { create } from 'zustand';
const useTaskStore = create((set, get) => ({
tasks: [],
filter: 'all',
isLoading: false,
setTasks: (tasks) => set({ tasks }),
addTask: (task) => set((state) => ({
tasks: [task, ...state.tasks]
})),
updateTask: (id, updates) => set((state) => ({
tasks: state.tasks.map(t =>
t.id === id ? { ...t, ...updates } : t
)
})),
deleteTask: (id) => set((state) => ({
tasks: state.tasks.filter(t => t.id !== id)
})),
setFilter: (filter) => set({ filter }),
// Derived state (selector)
getFilteredTasks: () => {
const { tasks, filter } = get();
switch (filter) {
case 'completed': return tasks.filter(t => t.completed);
case 'pending': return tasks.filter(t => !t.completed);
default: return tasks;
}
}
}));
// Usage in components
function TaskList() {
const tasks = useTaskStore((state) => state.getFilteredTasks());
const deleteTask = useTaskStore((state) => state.deleteTask);
return (
<ul>
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onDelete={() => deleteTask(task.id)}
/>
))}
</ul>
);
}Backend Service Patterns
The Service Layer Pattern
The service layer encapsulates business logic and orchestrates operations:
// services/TaskService.js
class TaskService {
constructor(taskRepository, userRepository, notificationService) {
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.notificationService = notificationService;
}
async createTask(taskData, creatorId) {
// Validate creator exists
const creator = await this.userRepository.findById(creatorId);
if (!creator) {
throw new NotFoundError('User not found');
}
// Create task entity
const task = new Task({
...taskData,
createdBy: creatorId,
status: 'pending'
});
// Persist
const saved = await this.taskRepository.save(task);
// Notify assignee if assigned
if (task.assigneeId) {
await this.notificationService.notifyTaskAssigned(saved);
}
return saved;
}
async assignTask(taskId, assigneeId, assignerId) {
const task = await this.taskRepository.findById(taskId);
if (!task) {
throw new NotFoundError('Task not found');
}
const assignee = await this.userRepository.findById(assigneeId);
if (!assignee) {
throw new NotFoundError('Assignee not found');
}
// Domain logic
task.assignTo(assigneeId);
await this.taskRepository.save(task);
await this.notificationService.notifyTaskAssigned(task, assignee);
return task;
}
async getTasksForUser(userId, filters = {}) {
const tasks = await this.taskRepository.findByAssignee(userId, filters);
return tasks;
}
}Controller Pattern
Controllers handle HTTP concerns and delegate to services:
// controllers/taskController.js
class TaskController {
constructor(taskService) {
this.taskService = taskService;
}
// GET /api/tasks
async list(req, res, next) {
try {
const filters = {
status: req.query.status,
assignee: req.query.assignee,
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 20
};
const result = await this.taskService.getTasks(filters);
res.json({
data: result.tasks.map(TaskDTO.fromDomain),
meta: {
total: result.total,
page: filters.page,
limit: filters.limit,
totalPages: Math.ceil(result.total / filters.limit)
}
});
} catch (error) {
next(error);
}
}
// GET /api/tasks/:id
async get(req, res, next) {
try {
const task = await this.taskService.getTaskById(req.params.id);
res.json({ data: TaskDTO.fromDomain(task) });
} catch (error) {
next(error);
}
}
// POST /api/tasks
async create(req, res, next) {
try {
const task = await this.taskService.createTask(
req.body,
req.user.id // From auth middleware
);
res.status(201).json({ data: TaskDTO.fromDomain(task) });
} catch (error) {
next(error);
}
}
// PUT /api/tasks/:id
async update(req, res, next) {
try {
const task = await this.taskService.updateTask(
req.params.id,
req.body,
req.user.id
);
res.json({ data: TaskDTO.fromDomain(task) });
} catch (error) {
next(error);
}
}
// DELETE /api/tasks/:id
async delete(req, res, next) {
try {
await this.taskService.deleteTask(req.params.id, req.user.id);
res.status(204).end();
} catch (error) {
next(error);
}
}
}
// Route setup
const taskController = new TaskController(taskService);
router.get('/', (req, res, next) => taskController.list(req, res, next));
router.get('/:id', (req, res, next) => taskController.get(req, res, next));
router.post('/', (req, res, next) => taskController.create(req, res, next));
router.put('/:id', (req, res, next) => taskController.update(req, res, next));
router.delete('/:id', (req, res, next) => taskController.delete(req, res, next));Middleware Pattern
Middleware functions process requests before they reach route handlers:
// middleware/auth.js
async function authenticate(req, res, next) {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
const decoded = verifyToken(token);
const user = await userRepository.findById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}
// middleware/validate.js
function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false,
stripUnknown: true
});
if (error) {
const errors = error.details.map(d => ({
field: d.path.join('.'),
message: d.message
}));
return res.status(400).json({ errors });
}
req.body = value; // Use validated/sanitized value
next();
};
}
// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
console.error(err);
if (err instanceof ValidationError) {
return res.status(400).json({
error: { code: 'VALIDATION_ERROR', message: err.message, details: err.details }
});
}
if (err instanceof NotFoundError) {
return res.status(404).json({
error: { code: 'NOT_FOUND', message: err.message }
});
}
if (err instanceof UnauthorizedError) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: err.message }
});
}
// Default server error
res.status(500).json({
error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' }
});
}
// Usage in app
app.use(express.json());
app.use('/api', authenticate);
app.use('/api/tasks', taskRoutes);
app.use(errorHandler);Data Transfer Objects (DTOs)
DTOs shape data for API responses, hiding internal details:
// dto/TaskDTO.js
class TaskDTO {
constructor(task) {
this.id = task.id;
this.title = task.title;
this.description = task.description;
this.status = task.status;
this.dueDate = task.dueDate?.toISOString();
this.createdAt = task.createdAt.toISOString();
this.updatedAt = task.updatedAt.toISOString();
// Include related data if populated
if (task.assignee) {
this.assignee = {
id: task.assignee.id,
name: task.assignee.name,
email: task.assignee.email
};
}
// Computed properties
this.isOverdue = task.isOverdue();
}
static fromDomain(task) {
return new TaskDTO(task);
}
static fromDomainList(tasks) {
return tasks.map(t => TaskDTO.fromDomain(t));
}
}
// Usage in controller
res.json({
data: TaskDTO.fromDomainList(tasks),
meta: { total: tasks.length }
});Input Validation
Validate incoming data before processing:
// validation/taskValidation.js
const Joi = require('joi');
const createTaskSchema = Joi.object({
title: Joi.string()
.min(1)
.max(200)
.required()
.messages({
'string.empty': 'Title is required',
'string.max': 'Title cannot exceed 200 characters'
}),
description: Joi.string()
.max(2000)
.optional(),
dueDate: Joi.date()
.min('now')
.optional()
.messages({
'date.min': 'Due date cannot be in the past'
}),
assigneeId: Joi.string()
.pattern(/^[a-f\d]{24}$/i) // MongoDB ObjectId
.optional(),
priority: Joi.string()
.valid('low', 'medium', 'high')
.default('medium')
});
const updateTaskSchema = Joi.object({
title: Joi.string().min(1).max(200),
description: Joi.string().max(2000),
dueDate: Joi.date(),
assigneeId: Joi.string().pattern(/^[a-f\d]{24}$/i),
priority: Joi.string().valid('low', 'medium', 'high'),
status: Joi.string().valid('pending', 'in_progress', 'completed')
}).min(1); // At least one field required
// Route with validation middleware
router.post('/', validate(createTaskSchema), taskController.create);
router.put('/:id', validate(updateTaskSchema), taskController.update);Putting It Together: Full Stack Data Flow
Here’s how data flows through a full-stack application:
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ TaskForm │───►│ useTasks │───►│ taskApi │ │
│ │ (Component) │ │ (Hook) │ │ (API) │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
└────────────────────────────────────────────────┼──────────────────┘
│ HTTP POST
│ /api/tasks
▼
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Router │───►│ Controller │───►│ Service │ │
│ │ (Express) │ │ (HTTP) │ │ (Business) │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Repository │ │
│ │ (Data) │ │
│ └──────┬──────┘ │
│ │ │
└────────────────────────────────────────────────┼──────────────────┘
│
▼
┌─────────────┐
│ MongoDB │
└─────────────┘
Example: Creating a Task
1. User fills form and submits:
// TaskForm.jsx
const handleSubmit = async (e) => {
e.preventDefault();
await createTask({ title, description, dueDate });
};2. Custom hook calls API:
// useTasks.js
const createTask = async (data) => {
const newTask = await taskApi.createTask(data);
setTasks(prev => [newTask, ...prev]);
return newTask;
};3. API layer makes HTTP request:
// taskApi.js
async createTask(data) {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}4. Backend validates and processes:
// Router
router.post('/', validate(createTaskSchema), (req, res, next) =>
taskController.create(req, res, next));
// Controller
async create(req, res, next) {
const task = await this.taskService.createTask(req.body, req.user.id);
res.status(201).json({ data: TaskDTO.fromDomain(task) });
}
// Service
async createTask(data, creatorId) {
const task = new Task({ ...data, createdBy: creatorId });
return await this.taskRepository.save(task);
}
// Repository
async save(task) {
const doc = await TaskModel.create(this.toDocument(task));
return this.toDomain(doc);
}Summary
This week we covered component architecture and state management:
- Component design principles: Single responsibility, composition, props down/events up
- Component categories: Presentational, container, and layout components
- Local state: useState for simple state, useReducer for complex logic
- Shared state: Context API for cross-component communication
- State management libraries: When and how to use Zustand or similar tools
- Backend service layer: Separating business logic from HTTP handling
- Middleware pattern: Processing requests through composable functions
- DTOs and validation: Shaping and validating data at boundaries
These patterns form the foundation for building maintainable full-stack applications.
Key Terms
- Component Composition: Building complex UIs by combining simple components
- Prop Drilling: Passing props through many component layers (often a sign to use Context)
- State Colocation: Keeping state as close as possible to where it’s used
- Lifting State Up: Moving state to a common ancestor when siblings need to share it
- Service Layer: Application layer containing business logic
- DTO (Data Transfer Object): Object shaped for transferring data across boundaries
- Middleware: Functions that process requests in a pipeline