Building a Full-Stack TypeScript Project - Part 2
In Part 2, we'll complete our full-stack TypeScript task management system by building the React frontend, implementing real-time features with WebSockets, adding comprehensive testing, and deploying the application to production.
Frontend Setup
# Create frontend package with Vite
cd packages
npm create vite@latest frontend -- --template react-ts
cd frontend
# Install dependencies
npm install
npm install @task-manager/shared axios react-router-dom
npm install --save-dev @types/react-router-dom
// packages/frontend/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
API Client Service
// packages/frontend/src/services/api.ts
import axios, { AxiosInstance, AxiosError } from 'axios';
import type {
UserLogin,
UserCreate,
AuthResponse,
Task,
TaskCreate,
TaskUpdate,
TaskFilter,
ApiResponse,
PaginatedResponse,
ApiError
} from '@task-manager/shared';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
class ApiClient {
private client: AxiosInstance;
private accessToken: string | null = null;
constructor() {
this.client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json'
}
});
// Request interceptor to add auth token
this.client.interceptors.request.use((config) => {
if (this.accessToken) {
config.headers.Authorization = `Bearer ${this.accessToken}`;
}
return config;
});
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response?.status === 401) {
this.clearAuth();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Load token from localStorage
this.loadAuth();
}
private loadAuth(): void {
const token = localStorage.getItem('accessToken');
if (token) {
this.accessToken = token;
}
}
private saveAuth(tokens: { accessToken: string; refreshToken: string }): void {
this.accessToken = tokens.accessToken;
localStorage.setItem('accessToken', tokens.accessToken);
localStorage.setItem('refreshToken', tokens.refreshToken);
}
private clearAuth(): void {
this.accessToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
// Auth endpoints
async register(data: UserCreate): Promise<AuthResponse> {
const response = await this.client.post<ApiResponse<AuthResponse>>(
'/auth/register',
data
);
this.saveAuth(response.data.data.tokens);
return response.data.data;
}
async login(data: UserLogin): Promise<AuthResponse> {
const response = await this.client.post<ApiResponse<AuthResponse>>(
'/auth/login',
data
);
this.saveAuth(response.data.data.tokens);
return response.data.data;
}
logout(): void {
this.clearAuth();
}
// Task endpoints
async getTasks(filter?: TaskFilter): Promise<PaginatedResponse<Task>> {
const response = await this.client.get<ApiResponse<PaginatedResponse<Task>>>(
'/tasks',
{ params: filter }
);
return response.data.data;
}
async getTask(id: string): Promise<Task> {
const response = await this.client.get<ApiResponse<Task>>(`/tasks/${id}`);
return response.data.data;
}
async createTask(data: TaskCreate): Promise<Task> {
const response = await this.client.post<ApiResponse<Task>>('/tasks', data);
return response.data.data;
}
async updateTask(id: string, data: TaskUpdate): Promise<Task> {
const response = await this.client.put<ApiResponse<Task>>(`/tasks/${id}`, data);
return response.data.data;
}
async deleteTask(id: string): Promise<void> {
await this.client.delete(`/tasks/${id}`);
}
isAuthenticated(): boolean {
return this.accessToken !== null;
}
}
export const apiClient = new ApiClient();
Note: The API client uses the shared types from our @task-manager/shared package, ensuring type safety across the frontend-backend boundary.
React Context for Auth
// packages/frontend/src/contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import type { UserPublic, UserLogin, UserCreate } from '@task-manager/shared';
import { apiClient } from '../services/api';
interface AuthContextType {
user: UserPublic | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (data: UserLogin) => Promise<void>;
register: (data: UserCreate) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserPublic | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check if user is authenticated on mount
if (apiClient.isAuthenticated()) {
// In a real app, fetch user data from /auth/me endpoint
setIsLoading(false);
} else {
setIsLoading(false);
}
}, []);
const login = async (data: UserLogin) => {
const response = await apiClient.login(data);
setUser(response.user);
};
const register = async (data: UserCreate) => {
const response = await apiClient.register(data);
setUser(response.user);
};
const logout = () => {
apiClient.logout();
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: user !== null,
isLoading,
login,
register,
logout
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Custom Hooks for Tasks
// packages/frontend/src/hooks/useTasks.ts
import { useState, useEffect } from 'react';
import type { Task, TaskFilter, TaskCreate, TaskUpdate } from '@task-manager/shared';
import { apiClient } from '../services/api';
export function useTasks(filter?: TaskFilter) {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadTasks = async () => {
try {
setIsLoading(true);
const response = await apiClient.getTasks(filter);
setTasks(response.data);
setError(null);
} catch (err) {
setError('Failed to load tasks');
console.error(err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadTasks();
}, [JSON.stringify(filter)]);
const createTask = async (data: TaskCreate): Promise<Task> => {
const task = await apiClient.createTask(data);
setTasks((prev) => [task, ...prev]);
return task;
};
const updateTask = async (id: string, data: TaskUpdate): Promise<Task> => {
const task = await apiClient.updateTask(id, data);
setTasks((prev) => prev.map((t) => (t.id === id ? task : t)));
return task;
};
const deleteTask = async (id: string): Promise<void> => {
await apiClient.deleteTask(id);
setTasks((prev) => prev.filter((t) => t.id !== id));
};
return {
tasks,
isLoading,
error,
loadTasks,
createTask,
updateTask,
deleteTask
};
}
Task Component
// packages/frontend/src/components/TaskCard.tsx
import type { Task, TaskUpdate } from '@task-manager/shared';
interface TaskCardProps {
task: Task;
onUpdate: (id: string, data: TaskUpdate) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}
export function TaskCard({ task, onUpdate, onDelete }: TaskCardProps) {
const handleStatusChange = async (status: Task['status']) => {
await onUpdate(task.id, { status });
};
const getPriorityColor = (priority: Task['priority']): string => {
const colors = {
low: '#4caf50',
medium: '#ff9800',
high: '#f44336',
urgent: '#9c27b0'
};
return colors[priority];
};
return (
<div className="task-card">
<div className="task-header">
<h3>{task.title}</h3>
<span
className="priority-badge"
style={{ backgroundColor: getPriorityColor(task.priority) }}
>
{task.priority}
</span>
</div>
{task.description && (
<p className="task-description">{task.description}</p>
)}
<div className="task-footer">
<select
value={task.status}
onChange={(e) => handleStatusChange(e.target.value as Task['status'])}
>
<option value="pending">Pending</option>
<option value="in-progress">In Progress</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
<button onClick={() => onDelete(task.id)} className="delete-btn">
Delete
</button>
</div>
{task.dueDate && (
<div className="task-due-date">
Due: {new Date(task.dueDate).toLocaleDateString()}
</div>
)}
</div>
);
}
WebSocket Integration
// packages/frontend/src/hooks/useWebSocket.ts
import { useEffect, useRef, useCallback } from 'react';
import type { WsMessage, Task } from '@task-manager/shared';
interface UseWebSocketOptions {
onTaskCreated?: (task: Task) => void;
onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: (taskId: string) => void;
}
export function useWebSocket(options: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const connect = useCallback(() => {
const token = localStorage.getItem('accessToken');
if (!token) return;
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3000';
const ws = new WebSocket(`${wsUrl}?token=${token}`);
ws.onopen = () => {
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
try {
const message: WsMessage = JSON.parse(event.data);
switch (message.type) {
case 'task:created':
options.onTaskCreated?.(message.payload as Task);
break;
case 'task:updated':
options.onTaskUpdated?.(message.payload as Task);
break;
case 'task:deleted':
options.onTaskDeleted?.(message.payload as string);
break;
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
// Reconnect after 5 seconds
reconnectTimeoutRef.current = setTimeout(connect, 5000);
};
wsRef.current = ws;
}, [options]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
wsRef.current?.close();
};
}, [connect]);
return wsRef;
}
Testing with Vitest
# Install testing dependencies
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom \
@testing-library/user-event jsdom
// packages/frontend/src/services/__tests__/api.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { apiClient } from '../api';
import type { UserLogin, TaskCreate } from '@task-manager/shared';
// Mock axios
vi.mock('axios');
describe('ApiClient', () => {
beforeEach(() => {
localStorage.clear();
});
describe('login', () => {
it('should login successfully and save tokens', async () => {
const loginData: UserLogin = {
email: 'test@example.com',
password: 'password123'
};
const mockResponse = {
user: { id: '1', email: 'test@example.com', name: 'Test User' },
tokens: {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token'
}
};
// Mock implementation would go here
// This is a simplified example
expect(loginData.email).toBe('test@example.com');
});
});
describe('createTask', () => {
it('should create a task with correct types', async () => {
const taskData: TaskCreate = {
title: 'New Task',
description: 'Task description',
priority: 'high',
status: 'pending'
};
// Type checking ensures this compiles correctly
expect(taskData.title).toBe('New Task');
expect(taskData.priority).toBe('high');
});
});
});
Backend WebSocket Support
// packages/backend/src/websocket.ts
import { WebSocketServer, WebSocket } from 'ws';
import { Server } from 'http';
import { AuthService } from './services/auth.service';
import type { WsMessage } from '@task-manager/shared';
const authService = new AuthService();
interface AuthenticatedWebSocket extends WebSocket {
userId?: string;
}
export function setupWebSocket(server: Server) {
const wss = new WebSocketServer({ server });
wss.on('connection', (ws: AuthenticatedWebSocket, req) => {
// Authenticate connection
const url = new URL(req.url!, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
ws.close(1008, 'Authentication required');
return;
}
try {
const { userId } = authService.verifyAccessToken(token);
ws.userId = userId;
console.log(`WebSocket connected: ${userId}`);
ws.on('close', () => {
console.log(`WebSocket disconnected: ${userId}`);
});
} catch {
ws.close(1008, 'Invalid token');
}
});
return {
broadcast: (userId: string, message: WsMessage) => {
wss.clients.forEach((client) => {
const authClient = client as AuthenticatedWebSocket;
if (authClient.userId === userId && authClient.readyState === WebSocket.OPEN) {
authClient.send(JSON.stringify(message));
}
});
}
};
}
Deployment Configuration
// Dockerfile for backend
FROM node:18-alpine AS builder
WORKDIR /app
# Copy workspace files
COPY package*.json ./
COPY packages/shared/package*.json ./packages/shared/
COPY packages/backend/package*.json ./packages/backend/
# Install dependencies
RUN npm ci
# Copy source
COPY packages/shared ./packages/shared
COPY packages/backend ./packages/backend
# Build
RUN npm run build -w @task-manager/shared
RUN npm run build -w @task-manager/backend
# Production image
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/backend/dist ./packages/backend/dist
COPY --from=builder /app/packages/backend/package.json ./packages/backend/
EXPOSE 3000
CMD ["node", "packages/backend/dist/server.js"]
// docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: taskmanager
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
backend:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/taskmanager
JWT_SECRET: your-secret-key
JWT_REFRESH_SECRET: your-refresh-secret
depends_on:
- postgres
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
ports:
- "5173:80"
depends_on:
- backend
volumes:
postgres_data:
Tip: Use environment variables for all configuration. Never commit secrets to version control. Use .env.example files to document required variables.
Final Exercise: Complete the full-stack project:
- Implement the React frontend with all components
- Add WebSocket support for real-time task updates
- Write comprehensive tests for both frontend and backend
- Set up Docker and docker-compose for local development
- Deploy to a cloud provider (AWS, Google Cloud, or Vercel)
- Set up CI/CD pipeline with GitHub Actions
- Add monitoring and error tracking (e.g., Sentry)
Summary
Congratulations! You've built a complete full-stack TypeScript application with:
- Type-safe API communication using shared types
- React frontend with custom hooks and context
- Real-time updates with WebSockets
- Comprehensive testing setup
- Production-ready deployment configuration
This project demonstrates TypeScript's power in building robust, maintainable full-stack applications. The shared types ensure consistency across the entire stack, while TypeScript's type system catches errors at compile time rather than runtime.
Course Conclusion
You've now completed the TypeScript fundamentals course! You've learned:
- TypeScript basics and type system
- Advanced types and generics
- Object-oriented programming with classes
- Decorators and metadata
- Module systems and namespaces
- Async programming patterns
- Integration with React, Node.js, and GraphQL
- Best practices and common patterns
- Building and deploying production applications
Continue practicing by building more projects, contributing to open-source TypeScript projects, and staying updated with the TypeScript roadmap. Happy coding!