Frontend Integration
Now let's create a React frontend component that interacts with your user profile backend service. You'll learn how to consume REST APIs, handle async operations, and manage state in a modern React application.
๐ฏ What You'll Buildโ
A User Profile Management UI with:
- Profile List: Display all user profiles
- Profile Form: Create and edit user profiles
- Profile Details: View individual profile information
- Error Handling: User-friendly error states
- Loading States: Proper async operation feedback
- Type Safety: Full TypeScript integration
๐ Prerequisitesโ
Ensure you have:
- โ Completed Hello World Example
- โ
User profile service running on
http://localhost:8080
- โ Node.js and npm installed
- โ Basic React and TypeScript knowledge
๐ Setting Up the Frontendโ
Create a Simple React Appโ
Since this is focused on EAF integration, we'll create a minimal React setup:
# Create a simple React component in the existing project
mkdir -p apps/user-profile-ui/src/components
mkdir -p apps/user-profile-ui/src/services
mkdir -p apps/user-profile-ui/src/types
TypeScript Typesโ
First, let's define our TypeScript interfaces:
// apps/user-profile-ui/src/types/UserProfile.ts
export interface UserProfile {
id: string;
name: string;
email: string;
bio: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateUserProfileRequest {
name: string;
email: string;
bio: string;
}
export interface UpdateUserProfileRequest {
name: string;
email: string;
bio: string;
}
export interface ApiError {
message: string;
status: number;
}
API Service Layerโ
Create a service layer to handle all API communications:
// apps/user-profile-ui/src/services/userProfileService.ts
import {
UserProfile,
CreateUserProfileRequest,
UpdateUserProfileRequest,
ApiError,
} from '../types/UserProfile';
const API_BASE_URL = 'http://localhost:8080/api/v1';
class UserProfileService {
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error: ApiError = {
message: `HTTP error! status: ${response.status}`,
status: response.status,
};
throw error;
}
return response.json();
}
async getAllProfiles(): Promise<UserProfile[]> {
const response = await fetch(`${API_BASE_URL}/user-profiles`);
return this.handleResponse<UserProfile[]>(response);
}
async getProfileById(id: string): Promise<UserProfile> {
const response = await fetch(`${API_BASE_URL}/user-profiles/${id}`);
return this.handleResponse<UserProfile>(response);
}
async createProfile(data: CreateUserProfileRequest): Promise<UserProfile> {
const response = await fetch(`${API_BASE_URL}/user-profiles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
return this.handleResponse<UserProfile>(response);
}
async updateProfile(id: string, data: UpdateUserProfileRequest): Promise<UserProfile> {
const response = await fetch(`${API_BASE_URL}/user-profiles/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
return this.handleResponse<UserProfile>(response);
}
async deactivateProfile(id: string): Promise<UserProfile> {
const response = await fetch(`${API_BASE_URL}/user-profiles/${id}`, {
method: 'DELETE',
});
return this.handleResponse<UserProfile>(response);
}
async checkHealth(): Promise<{ status: string }> {
const response = await fetch(`${API_BASE_URL}/user-profiles/health`);
return this.handleResponse<{ status: string }>(response);
}
}
export const userProfileService = new UserProfileService();
๐งฉ React Componentsโ
User Profile List Componentโ
// apps/user-profile-ui/src/components/UserProfileList.tsx
import React, { useState, useEffect } from 'react';
import { UserProfile, ApiError } from '../types/UserProfile';
import { userProfileService } from '../services/userProfileService';
interface UserProfileListProps {
onSelectProfile: (profile: UserProfile) => void;
onEditProfile: (profile: UserProfile) => void;
refreshTrigger: number;
}
export const UserProfileList: React.FC<UserProfileListProps> = ({
onSelectProfile,
onEditProfile,
refreshTrigger,
}) => {
const [profiles, setProfiles] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadProfiles = async () => {
try {
setLoading(true);
setError(null);
const data = await userProfileService.getAllProfiles();
setProfiles(data);
} catch (err) {
const apiError = err as ApiError;
setError(apiError.message || 'Failed to load profiles');
} finally {
setLoading(false);
}
};
const handleDeactivate = async (id: string) => {
try {
await userProfileService.deactivateProfile(id);
await loadProfiles(); // Refresh the list
} catch (err) {
const apiError = err as ApiError;
alert(`Failed to deactivate profile: ${apiError.message}`);
}
};
useEffect(() => {
loadProfiles();
}, [refreshTrigger]);
if (loading) {
return (
<div className="user-profile-list">
<h2>User Profiles</h2>
<div className="loading">Loading profiles...</div>
</div>
);
}
if (error) {
return (
<div className="user-profile-list">
<h2>User Profiles</h2>
<div className="error">
<p>Error: {error}</p>
<button onClick={loadProfiles}>Retry</button>
</div>
</div>
);
}
return (
<div className="user-profile-list">
<h2>User Profiles ({profiles.length})</h2>
{profiles.length === 0 ? (
<p>No profiles found. Create your first profile!</p>
) : (
<div className="profile-grid">
{profiles.map((profile) => (
<div
key={profile.id}
className={`profile-card ${!profile.isActive ? 'inactive' : ''}`}
>
<div className="profile-header">
<h3>{profile.name}</h3>
<span className={`status ${profile.isActive ? 'active' : 'inactive'}`}>
{profile.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<p className="email">{profile.email}</p>
<p className="bio">{profile.bio}</p>
<div className="profile-actions">
<button
onClick={() => onSelectProfile(profile)}
className="btn-secondary"
>
View Details
</button>
<button
onClick={() => onEditProfile(profile)}
className="btn-primary"
disabled={!profile.isActive}
>
Edit
</button>
{profile.isActive && (
<button
onClick={() => {
if (confirm(`Are you sure you want to deactivate ${profile.name}?`)) {
handleDeactivate(profile.id);
}
}}
className="btn-danger"
>
Deactivate
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};
User Profile Form Componentโ
// apps/user-profile-ui/src/components/UserProfileForm.tsx
import React, { useState, useEffect } from 'react';
import { UserProfile, CreateUserProfileRequest, UpdateUserProfileRequest, ApiError } from '../types/UserProfile';
import { userProfileService } from '../services/userProfileService';
interface UserProfileFormProps {
profile?: UserProfile;
onSuccess: (profile: UserProfile) => void;
onCancel: () => void;
}
export const UserProfileForm: React.FC<UserProfileFormProps> = ({
profile,
onSuccess,
onCancel,
}) => {
const [formData, setFormData] = useState({
name: profile?.name || '',
email: profile?.email || '',
bio: profile?.bio || '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const isEdit = !!profile;
useEffect(() => {
if (profile) {
setFormData({
name: profile.name,
email: profile.email,
bio: profile.bio,
});
}
}, [profile]);
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.name.trim()) {
errors.name = 'Name is required';
}
if (!formData.email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Please enter a valid email address';
}
if (!formData.bio.trim()) {
errors.bio = 'Bio is required';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
// Clear validation error when user starts typing
if (validationErrors[name]) {
setValidationErrors(prev => ({
...prev,
[name]: '',
}));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setLoading(true);
setError(null);
let result: UserProfile;
if (isEdit && profile) {
const updateData: UpdateUserProfileRequest = {
name: formData.name.trim(),
email: formData.email.trim(),
bio: formData.bio.trim(),
};
result = await userProfileService.updateProfile(profile.id, updateData);
} else {
const createData: CreateUserProfileRequest = {
name: formData.name.trim(),
email: formData.email.trim(),
bio: formData.bio.trim(),
};
result = await userProfileService.createProfile(createData);
}
onSuccess(result);
} catch (err) {
const apiError = err as ApiError;
setError(apiError.message || `Failed to ${isEdit ? 'update' : 'create'} profile`);
} finally {
setLoading(false);
}
};
return (
<div className="user-profile-form">
<h2>{isEdit ? 'Edit Profile' : 'Create New Profile'}</h2>
{error && (
<div className="error-message">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleInputChange}
className={validationErrors.name ? 'error' : ''}
placeholder="Enter full name"
/>
{validationErrors.name && (
<span className="validation-error">{validationErrors.name}</span>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className={validationErrors.email ? 'error' : ''}
placeholder="Enter email address"
/>
{validationErrors.email && (
<span className="validation-error">{validationErrors.email}</span>
)}
</div>
<div className="form-group">
<label htmlFor="bio">Bio *</label>
<textarea
id="bio"
name="bio"
value={formData.bio}
onChange={handleInputChange}
className={validationErrors.bio ? 'error' : ''}
placeholder="Tell us about yourself"
rows={4}
/>
{validationErrors.bio && (
<span className="validation-error">{validationErrors.bio}</span>
)}
</div>
<div className="form-actions">
<button
type="button"
onClick={onCancel}
className="btn-secondary"
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={loading}
>
{loading ? 'Saving...' : (isEdit ? 'Update Profile' : 'Create Profile')}
</button>
</div>
</form>
</div>
);
};
User Profile Details Componentโ
// apps/user-profile-ui/src/components/UserProfileDetails.tsx
import React from 'react';
import { UserProfile } from '../types/UserProfile';
interface UserProfileDetailsProps {
profile: UserProfile;
onEdit: () => void;
onBack: () => void;
}
export const UserProfileDetails: React.FC<UserProfileDetailsProps> = ({
profile,
onEdit,
onBack,
}) => {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
return (
<div className="user-profile-details">
<div className="details-header">
<button onClick={onBack} className="btn-secondary">
โ Back to List
</button>
<h2>Profile Details</h2>
</div>
<div className="profile-info">
<div className="info-section">
<h3>Basic Information</h3>
<div className="info-grid">
<div className="info-item">
<label>Name</label>
<p>{profile.name}</p>
</div>
<div className="info-item">
<label>Email</label>
<p>{profile.email}</p>
</div>
<div className="info-item">
<label>Status</label>
<p className={`status ${profile.isActive ? 'active' : 'inactive'}`}>
{profile.isActive ? 'Active' : 'Inactive'}
</p>
</div>
</div>
</div>
<div className="info-section">
<h3>Bio</h3>
<p className="bio-text">{profile.bio}</p>
</div>
<div className="info-section">
<h3>Metadata</h3>
<div className="info-grid">
<div className="info-item">
<label>Profile ID</label>
<p className="monospace">{profile.id}</p>
</div>
<div className="info-item">
<label>Created</label>
<p>{formatDate(profile.createdAt)}</p>
</div>
<div className="info-item">
<label>Last Updated</label>
<p>{formatDate(profile.updatedAt)}</p>
</div>
</div>
</div>
</div>
<div className="details-actions">
{profile.isActive && (
<button onClick={onEdit} className="btn-primary">
Edit Profile
</button>
)}
</div>
</div>
);
};
Main Application Componentโ
// apps/user-profile-ui/src/components/UserProfileApp.tsx
import React, { useState, useEffect } from 'react';
import { UserProfile } from '../types/UserProfile';
import { UserProfileList } from './UserProfileList';
import { UserProfileForm } from './UserProfileForm';
import { UserProfileDetails } from './UserProfileDetails';
import { userProfileService } from '../services/userProfileService';
type ViewMode = 'list' | 'create' | 'edit' | 'details';
export const UserProfileApp: React.FC = () => {
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedProfile, setSelectedProfile] = useState<UserProfile | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [connectionStatus, setConnectionStatus] = useState<'checking' | 'connected' | 'error'>('checking');
useEffect(() => {
// Check backend connection on startup
checkBackendConnection();
}, []);
const checkBackendConnection = async () => {
try {
await userProfileService.checkHealth();
setConnectionStatus('connected');
} catch (error) {
setConnectionStatus('error');
}
};
const handleCreateNew = () => {
setSelectedProfile(null);
setViewMode('create');
};
const handleSelectProfile = (profile: UserProfile) => {
setSelectedProfile(profile);
setViewMode('details');
};
const handleEditProfile = (profile: UserProfile) => {
setSelectedProfile(profile);
setViewMode('edit');
};
const handleFormSuccess = () => {
setViewMode('list');
setSelectedProfile(null);
setRefreshTrigger(prev => prev + 1); // Trigger list refresh
};
const handleFormCancel = () => {
setViewMode('list');
setSelectedProfile(null);
};
const handleBackToList = () => {
setViewMode('list');
setSelectedProfile(null);
};
if (connectionStatus === 'checking') {
return (
<div className="app-container">
<div className="loading-screen">
<h2>Connecting to User Profile Service...</h2>
<div className="spinner"></div>
</div>
</div>
);
}
if (connectionStatus === 'error') {
return (
<div className="app-container">
<div className="error-screen">
<h2>Connection Error</h2>
<p>Unable to connect to the user profile service.</p>
<p>Please ensure the backend service is running on http://localhost:8080</p>
<button onClick={checkBackendConnection} className="btn-primary">
Retry Connection
</button>
</div>
</div>
);
}
return (
<div className="app-container">
<header className="app-header">
<h1>User Profile Management</h1>
<div className="connection-status">
<span className="status-indicator connected"></span>
Connected to EAF Backend
</div>
</header>
<main className="app-main">
{viewMode === 'list' && (
<div>
<div className="list-header">
<button onClick={handleCreateNew} className="btn-primary">
Create New Profile
</button>
</div>
<UserProfileList
onSelectProfile={handleSelectProfile}
onEditProfile={handleEditProfile}
refreshTrigger={refreshTrigger}
/>
</div>
)}
{(viewMode === 'create' || viewMode === 'edit') && (
<UserProfileForm
profile={selectedProfile || undefined}
onSuccess={handleFormSuccess}
onCancel={handleFormCancel}
/>
)}
{viewMode === 'details' && selectedProfile && (
<UserProfileDetails
profile={selectedProfile}
onEdit={() => handleEditProfile(selectedProfile)}
onBack={handleBackToList}
/>
)}
</main>
</div>
);
};
๐จ Basic CSS Stylingโ
Create basic CSS for a clean, functional interface:
/* apps/user-profile-ui/src/styles/app.css */
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-indicator.connected {
background-color: #4caf50;
}
/* Buttons */
.btn-primary,
.btn-secondary,
.btn-danger {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background-color: #2196f3;
color: white;
}
.btn-primary:hover {
background-color: #1976d2;
}
.btn-secondary {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
.btn-secondary:hover {
background-color: #e0e0e0;
}
.btn-danger {
background-color: #f44336;
color: white;
}
.btn-danger:hover {
background-color: #d32f2f;
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Profile Grid */
.profile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.profile-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.profile-card.inactive {
opacity: 0.6;
background-color: #f9f9f9;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.status.active {
color: #4caf50;
font-weight: 500;
}
.status.inactive {
color: #f44336;
font-weight: 500;
}
.profile-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
.profile-actions button {
flex: 1;
padding: 8px 12px;
font-size: 12px;
}
/* Forms */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group input.error,
.form-group textarea.error {
border-color: #f44336;
}
.validation-error {
display: block;
color: #f44336;
font-size: 12px;
margin-top: 5px;
}
.form-actions {
display: flex;
gap: 15px;
justify-content: flex-end;
margin-top: 30px;
}
/* Loading and Error States */
.loading,
.error-message {
text-align: center;
padding: 40px;
color: #666;
}
.error-message {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
border-radius: 4px;
margin-bottom: 20px;
}
.loading-screen,
.error-screen {
text-align: center;
padding: 60px 20px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2196f3;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Details View */
.details-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 30px;
}
.info-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 15px;
}
.info-item label {
display: block;
font-weight: 500;
color: #666;
margin-bottom: 5px;
}
.monospace {
font-family: 'Courier New', monospace;
font-size: 12px;
background-color: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
}
.bio-text {
line-height: 1.6;
color: #333;
margin-top: 10px;
}
๐งช Testing Your Frontendโ
1. Start Your Backend Serviceโ
# Ensure infrastructure is running
cd infra/docker-compose && docker compose up -d && cd ../..
# Start your user profile service
nx run user-profile:run
2. Create a Simple HTML Test Pageโ
<!-- apps/user-profile-ui/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>User Profile Management</title>
<link rel="stylesheet" href="src/styles/app.css" />
</head>
<body>
<div id="app"></div>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
// Import your components here and render the app
// For a full setup, you'd typically use a build tool like Vite or Create React App
const App = () => {
return React.createElement(
'div',
{ className: 'app-container' },
React.createElement('h1', null, 'User Profile Management'),
React.createElement(
'p',
null,
'Frontend integration example - see the component files for full implementation!'
)
);
};
ReactDOM.render(React.createElement(App), document.getElementById('app'));
</script>
</body>
</html>
3. Test API Integrationโ
You can test the API service independently:
// Test in browser console or create a separate test file
import { userProfileService } from './services/userProfileService';
// Test creating a profile
const testProfile = {
name: 'Jane Doe',
email: 'jane.doe@example.com',
bio: 'Frontend Developer',
};
userProfileService
.createProfile(testProfile)
.then(profile => console.log('Created:', profile))
.catch(error => console.error('Error:', error));
// Test getting all profiles
userProfileService
.getAllProfiles()
.then(profiles => console.log('All profiles:', profiles))
.catch(error => console.error('Error:', error));
๐ฏ Key Frontend Patternsโ
Error Handling Strategyโ
// Centralized error handling
const handleApiError = (error: unknown) => {
if (error instanceof ApiError) {
// Handle known API errors
return error.message;
}
// Handle unexpected errors
return 'An unexpected error occurred';
};
Loading State Managementโ
// Consistent loading patterns
const [loading, setLoading] = useState(false);
const performAsyncOperation = async () => {
try {
setLoading(true);
setError(null);
// Perform operation
const result = await apiCall();
// Handle success
} catch (error) {
// Handle error
} finally {
setLoading(false);
}
};
Type-Safe API Callsโ
// Always use TypeScript interfaces
const createProfile = async (data: CreateUserProfileRequest): Promise<UserProfile> => {
// Implementation with proper typing
};
โ Success Criteriaโ
You've successfully integrated the frontend if:
- โ Components render without errors
- โ API calls work correctly with proper error handling
- โ Loading states provide good user feedback
- โ Form validation works client-side
- โ CRUD operations work end-to-end
- โ TypeScript provides full type safety
๐ Next Stepsโ
Great job! You now have a complete full-stack application. Continue to Development Workflow to learn about day-to-day development practices and tooling.
Fantastic! You've successfully integrated a React frontend with your EAF backend service! ๐