Week 4: Component Architecture

State Management and Backend Patterns

NoteReading Assignment

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>
  );
}

React Context for Shared State

Context provides a way to share state across the component tree without prop drilling:

// AuthContext.jsx
const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Check for existing session on mount
    checkAuthStatus().then(user => {
      setUser(user);
      setLoading(false);
    });
  }, []);

  const login = async (credentials) => {
    const user = await authApi.login(credentials);
    setUser(user);
    return user;
  };

  const logout = async () => {
    await authApi.logout();
    setUser(null);
  };

  const value = {
    user,
    isAuthenticated: !!user,
    loading,
    login,
    logout
  };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook for consuming context
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

// Usage in components
function UserMenu() {
  const { user, logout, isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <LoginButton />;
  }

  return (
    <div className="user-menu">
      <span>Welcome, {user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

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 defaults

State 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:

  1. Component design principles: Single responsibility, composition, props down/events up
  2. Component categories: Presentational, container, and layout components
  3. Local state: useState for simple state, useReducer for complex logic
  4. Shared state: Context API for cross-component communication
  5. State management libraries: When and how to use Zustand or similar tools
  6. Backend service layer: Separating business logic from HTTP handling
  7. Middleware pattern: Processing requests through composable functions
  8. 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

Further Reading