A MEAN Stack application is only as good as the connection between its two halves. The Angular frontend and the Express API must speak the same language: consistent response envelopes, predictable error codes, typed interfaces shared across both ends, and a data-fetching architecture that handles loading, error, and empty states elegantly. This lesson establishes the full-stack data flow conventions that make the rest of the integration patterns in this chapter possible — from the API response contract to the Angular service layer that consumes it.
Full-Stack Data Contract
| Concern | Express (Server) | Angular (Client) |
|---|---|---|
| Success response shape | { success: true, data: T, meta?: Pagination } |
ApiResponse<T> interface — map to data in ApiService |
| Error response shape | { message: string, code?: string, errors?: FieldError[] } |
Read err.error.message in catchError handler |
| HTTP status codes | 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 429 Too Many Requests, 500 Server Error | Switch on err.status in error interceptor |
| Pagination headers | X-Total-Count: 47 response header |
observe: 'response' to read headers |
| TypeScript models | Mongoose schema shape informs interface | Shared task.model.ts interfaces |
UI State Machine
| State | Signal Values | Template Shows |
|---|---|---|
| Idle | loading=false, data=[], error=null |
Empty prompt or initial placeholder |
| Loading | loading=true, data=[], error=null |
Skeleton loaders or spinner |
| Loaded | loading=false, data=[...], error=null |
Data list or detail view |
| Empty | loading=false, data=[], error=null |
Empty state illustration + CTA |
| Error | loading=false, data=[], error='msg' |
Error message + retry button |
{ success: true, data: T } for success and { message: string } for errors, the Angular ApiService can map all responses uniformly with one map(r => r.data) operator. Without this convention, every service method must handle a different response shape — multiplying the places where bugs can hide.types/ folder at the monorepo root — or duplicate a minimal set in shared/models/ on the Angular side — as the single source of truth.{ data: [...] } on success but { errors: [...] } (no data) on validation failure — forcing the client to check which shape it received before accessing anything. Standardise: success always has data, errors always have message, optionally with an errors array for field-level validation. The HTTP status code (400 vs 200) tells the client which shape to expect.Complete Full-Stack Data Flow
// ── Express: consistent response helper ──────────────────────────────────
// utils/response.js
const success = (res, data, statusCode = 200, meta = undefined) =>
res.status(statusCode).json({ success: true, data, ...(meta ? { meta } : {}) });
const created = (res, data) => success(res, data, 201);
const paginated = (res, data, total, page, limit) => {
const totalPages = Math.ceil(total / limit);
res.setHeader('X-Total-Count', total);
return success(res, data, 200, {
total, page, limit, totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
});
};
module.exports = { success, created, paginated };
// ── Express: task controller ──────────────────────────────────────────────
const { success, created, paginated } = require('../utils/response');
const asyncHandler = require('express-async-handler');
const Task = require('../models/task.model');
exports.getAll = asyncHandler(async (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 10);
const skip = (page - 1) * limit;
const filter = { user: req.user.sub, deletedAt: { $exists: false } };
if (req.query.status) filter.status = req.query.status;
if (req.query.priority) filter.priority = req.query.priority;
if (req.query.q) {
filter.$text = { $search: req.query.q };
}
const [tasks, total] = await Promise.all([
Task.find(filter).sort('-createdAt').skip(skip).limit(limit).lean(),
Task.countDocuments(filter),
]);
paginated(res, tasks, total, page, limit);
});
exports.getById = asyncHandler(async (req, res) => {
const task = await Task.findOne({ _id: req.params.id, user: req.user.sub }).lean();
if (!task) return res.status(404).json({ message: 'Task not found' });
success(res, task);
});
exports.create = asyncHandler(async (req, res) => {
const task = await Task.create({ ...req.body, user: req.user.sub });
created(res, task);
});
exports.update = asyncHandler(async (req, res) => {
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, user: req.user.sub },
req.body,
{ new: true, runValidators: true }
);
if (!task) return res.status(404).json({ message: 'Task not found' });
success(res, task);
});
exports.remove = asyncHandler(async (req, res) => {
const task = await Task.findOneAndDelete({ _id: req.params.id, user: req.user.sub });
if (!task) return res.status(404).json({ message: 'Task not found' });
success(res, null, 204);
});
// ── Angular: shared model interfaces ────────────────────────────────────
// shared/models/task.model.ts
export interface Task {
_id: string;
title: string;
description?: string;
status: 'pending' | 'in-progress' | 'completed';
priority: 'low' | 'medium' | 'high';
dueDate?: string; // ISO date string
tags: string[];
user: string;
createdAt: string;
updatedAt: string;
isOverdue?: boolean; // virtual from server
}
export interface CreateTaskDto {
title: string;
description?: string;
priority?: Task['priority'];
dueDate?: string;
tags?: string[];
}
export type UpdateTaskDto = Partial<CreateTaskDto> & { status?: Task['status'] };
export interface TaskQueryParams {
page?: number;
limit?: number;
status?: Task['status'];
priority?: Task['priority'];
q?: string;
sort?: string;
}
// ── Angular: complete task store with state machine ───────────────────────
// core/stores/task.store.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { TaskService } from '../services/task.service';
import { ToastService } from '../services/toast.service';
import { Task, CreateTaskDto, UpdateTaskDto, TaskQueryParams } from '../../shared/models/task.model';
import { PaginationMeta } from '../services/api.service';
type LoadState = 'idle' | 'loading' | 'loaded' | 'error';
@Injectable({ providedIn: 'root' })
export class TaskStore {
private taskService = inject(TaskService);
private toast = inject(ToastService);
// ── Private state signals ─────────────────────────────────────────────
private _tasks = signal<Task[]>([]);
private _loadState = signal<LoadState>('idle');
private _error = signal<string | null>(null);
private _meta = signal<PaginationMeta | null>(null);
private _pending = signal<Set<string>>(new Set());
private _params = signal<TaskQueryParams>({ page: 1, limit: 10 });
// ── Public read-only state ────────────────────────────────────────────
readonly tasks = this._tasks.asReadonly();
readonly loadState = this._loadState.asReadonly();
readonly error = this._error.asReadonly();
readonly meta = this._meta.asReadonly();
readonly params = this._params.asReadonly();
// ── Derived state ─────────────────────────────────────────────────────
readonly isLoading = computed(() => this._loadState() === 'loading');
readonly isLoaded = computed(() => this._loadState() === 'loaded');
readonly isEmpty = computed(() => this.isLoaded() && this._tasks().length === 0);
readonly hasError = computed(() => this._loadState() === 'error');
readonly isPending = (id: string) => computed(() => this._pending().has(id));
readonly overdueTasks = computed(() =>
this._tasks().filter(t =>
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'completed'
)
);
// ── Actions ───────────────────────────────────────────────────────────
load(params?: TaskQueryParams): void {
const merged = { ...this._params(), ...params };
this._params.set(merged);
this._loadState.set('loading');
this._error.set(null);
this.taskService.getAll(merged).subscribe({
next: ({ data, meta }) => {
this._tasks.set(data);
this._meta.set(meta);
this._loadState.set('loaded');
},
error: err => {
this._error.set(err.error?.message ?? 'Failed to load tasks');
this._loadState.set('error');
},
});
}
create(dto: CreateTaskDto): void {
this.taskService.create(dto).subscribe({
next: task => {
this._tasks.update(tasks => [task, ...tasks]);
this._meta.update(m => m ? { ...m, total: m.total + 1 } : m);
this.toast.success('Task created');
},
error: err => this.toast.error(err.error?.message ?? 'Failed to create task'),
});
}
update(id: string, dto: UpdateTaskDto): void {
this._setPending(id, true);
this.taskService.update(id, dto).subscribe({
next: updated => {
this._tasks.update(tasks => tasks.map(t => t._id === id ? updated : t));
this._setPending(id, false);
this.toast.success('Task updated');
},
error: err => {
this.toast.error(err.error?.message ?? 'Failed to update task');
this._setPending(id, false);
},
});
}
delete(id: string): void {
this._setPending(id, true);
this.taskService.delete(id).subscribe({
next: () => {
this._tasks.update(tasks => tasks.filter(t => t._id !== id));
this._meta.update(m => m ? { ...m, total: m.total - 1 } : m);
this._setPending(id, false);
this.toast.success('Task deleted');
},
error: err => {
this.toast.error(err.error?.message ?? 'Failed to delete task');
this._setPending(id, false);
},
});
}
private _setPending(id: string, v: boolean): void {
this._pending.update(s => {
const n = new Set(s); v ? n.add(id) : n.delete(id); return n;
});
}
}
How It Works
Step 1 — Response Helpers Enforce the Contract
The success(), created(), and paginated() helper functions in Express ensure every response follows the same shape. Controllers call these helpers instead of calling res.json() directly — making it impossible to accidentally return a different shape. When the contract needs to change (adding a requestId field to every response), updating the helper changes every response at once.
Step 2 — The State Machine Prevents Invalid UI States
The LoadState type ('idle' | 'loading' | 'loaded' | 'error') ensures the UI can only be in one state at a time. Setting loading: true AND error: 'message' simultaneously is not possible — state transitions are controlled through the _loadState signal. The template renders different UI for each state using @if/@else blocks, guided by the computed isLoading, isLoaded, isEmpty, and hasError signals.
Step 3 — Pagination Meta Enables Cursor Navigation
The server sends both the current page’s data and pagination metadata (total count, current page, pages available) in the same response. The client stores this metadata in _meta signal and uses it to render page controls, disable “Next” when on the last page, and show “X of Y results”. The X-Total-Count header approach (also used) allows the Angular ApiService to read it with observe: 'response'.
Step 4 — Per-Item Pending State Enables Granular Loading UX
The _pending Set tracks which task IDs have in-flight requests. The computed isPending(id) factory returns a computed signal for that specific ID. Task cards can show a loading indicator or disable buttons for the one task being processed, while all other tasks remain interactive. This is far better UX than a global loading flag that disables the entire list while one item is being updated.
Step 5 — Errors Are Surfaced to the User, Not Silently Swallowed
Every action handler has an error: callback that calls toast.error() with the API’s error message. API error messages are designed to be user-readable — “Title is required”, “Task not found”, “You have reached your task limit”. The err.error?.message ?? 'fallback' pattern reads the message from the parsed JSON response body with a safe fallback for network errors that have no JSON body.
Common Mistakes
Mistake 1 — Different response shapes for success and error
❌ Wrong — client must check which shape was returned:
// Success: { tasks: [...] }
// Error: { errors: ['Title required'] }
// Client: if (res.tasks) { ... } else if (res.errors) { ... } // fragile
✅ Correct — consistent envelope, HTTP status distinguishes success from error:
// 200: { success: true, data: [...] }
// 400: { message: 'Validation failed', errors: [{field: 'title', msg: 'required'}] }
Mistake 2 — No empty state — blank screen confuses users
❌ Wrong — component renders nothing when data array is empty:
@if (store.isLoaded()) {
@for (task of store.tasks(); track task._id) { <app-task-card [task]="task"> }
} <!-- empty list = blank white screen -->
✅ Correct — explicit empty state with a call to action:
@if (store.isEmpty()) {
<div class="empty-state">
<p>No tasks yet.</p>
<button routerLink="/tasks/new">Create your first task</button>
</div>
}
Mistake 3 — Loading state not cleared on error
❌ Wrong — spinner runs forever after API error:
this.taskService.getAll().subscribe({
next: tasks => { this.tasks.set(tasks); this.loading.set(false); },
error: err => { this.error.set(err.message); } // loading never cleared!
});
✅ Correct — clear loading in both next and error:
this.taskService.getAll().subscribe({
next: tasks => { this.tasks.set(tasks); this._loadState.set('loaded'); },
error: err => { this._error.set(err.message); this._loadState.set('error'); },
});
Quick Reference
| Task | Pattern |
|---|---|
| Express success response | res.status(200).json({ success: true, data: result }) |
| Express error response | res.status(400).json({ message: 'Validation failed' }) |
| Angular read error message | err.error?.message ?? 'Unknown error' |
| Angular read response header | http.get(url, { observe: 'response' }).pipe(map(r => r.headers.get('X-Total-Count'))) |
| State machine signal | private _state = signal<'idle'|'loading'|'loaded'|'error'>('idle') |
| Per-item pending | private _pending = signal<Set<string>>(new Set()) |
| Empty state check | isEmpty = computed(() => isLoaded() && tasks().length === 0) |