TypeScript

Building a Full-Stack TypeScript Project - Part 2

30 min Lesson 40 of 40

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:
  1. Implement the React frontend with all components
  2. Add WebSocket support for real-time task updates
  3. Write comprehensive tests for both frontend and backend
  4. Set up Docker and docker-compose for local development
  5. Deploy to a cloud provider (AWS, Google Cloud, or Vercel)
  6. Set up CI/CD pipeline with GitHub Actions
  7. 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!