Stage 2: Interactive UI
Enhancing the Chat App with state management, forms, and user feedback
Learning Objectives
By the end of this stage, you will:
- Implement proper state management patterns
- Handle forms with validation
- Provide user feedback (loading, errors, success)
- Add CSS transitions and animations (concepts that translate to Tailwind's animation utilities in later stages)
- Persist data with localStorage
- Recognize patterns that frameworks solve
Time: 3-4 hours (reading + building)
Introduction
Stage 1 built a working chat app. But it has limitations:
- Refreshing the page loses the messages
- No way to edit or delete messages
- No feedback when actions happen
- No user information for the messages
Stage 2 addresses these with enhanced interactivity — the kind of polish that makes applications feel professional.
More importantly, you'll see patterns emerge that motivate frameworks like React.
What We're Adding
┌─────────────────────────────────────────────────────────────┐
│ NEW: State Management │
│ • Centralized state object │
│ • State update functions │
│ • Automatic re-rendering on state change │
├─────────────────────────────────────────────────────────────┤
│ NEW: Enhanced Messages │
│ • Edit existing messages │
│ • Delete messages │
│ • Persistence in localStorage │
├─────────────────────────────────────────────────────────────┤
│ NEW: User Settings │
│ • Username configuration │
│ • Form validation │
│ • Settings confirmation │
├─────────────────────────────────────────────────────────────┤
│ NEW: Visual Feedback │
│ • Button animations │
│ • Toast notifications │
│ • Loading states │
└─────────────────────────────────────────────────────────────┘
Part 1: State Management
The Problem with Ad-Hoc State
In Stage 1, state was simple:
let messages = [];
But as features grow, you end up with:
let messages = [];
let username = '';
let userEmail = '';
let isEditingMessage = false;
let editError = null;
let lastNotification = null;
// ... and more
Scattered state leads to bugs. Which function updates what? Did you forget to re-render?
Centralized State
Collect all state in one place:
// Application state
const state = {
chat: {
messages: [], // Array of { id, text, username, timestamp }
username: '',
userEmail: ''
},
ui: {
isEditing: false,
editingMessageId: null,
notification: null, // { type: 'success'|'error', message: string }
activePanel: 'chat' // 'chat' | 'settings' | 'confirmation'
}
};
State Update Pattern
Never modify state directly. Use functions:
// ❌ Direct mutation (hard to track, no re-render)
state.chat.messages.push(message);
// ✅ Update function (predictable, triggers re-render)
function updateState(path, value) {
// Set nested property by path
const keys = path.split('.');
let obj = state;
for (let i = 0; i < keys.length - 1; i++) {
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
// Persist and re-render
saveState();
render();
}
The Render Loop
Central render function that updates everything:
function render() {
renderMessageInput();
renderMessages();
renderSettings();
renderNotification();
}
This is the pattern React uses — state changes trigger re-renders.
Part 2: Enhanced Message Management
Message Structure
Each message has a unique ID for editing and deletion:
// Stage 1: Simple messages
messages = [
{ text: 'Hello!', username: 'Alice' },
{ text: 'Hi there!', username: 'Bob' },
];
// Stage 2: Messages with IDs and timestamps
state.chat.messages = [
{ id: 'msg-1', text: 'Hello!', username: 'Alice', timestamp: 1699000000000 },
{ id: 'msg-2', text: 'Hi there!', username: 'Bob', timestamp: 1699000001000 }
];
Send Message (Improved)
function sendMessage(text) {
const messages = [...state.chat.messages];
const newMessage = {
id: `msg-${Date.now()}`,
text: text.trim(),
username: state.chat.username || 'Anonymous',
timestamp: Date.now()
};
messages.push(newMessage);
updateState('chat.messages', messages);
showNotification('success', 'Message sent!');
}
Edit and Delete Controls
function editMessage(messageId, newText) {
const messages = [...state.chat.messages];
const message = messages.find(m => m.id === messageId);
if (!message) return;
message.text = newText.trim();
message.edited = true;
updateState('chat.messages', messages);
updateState('ui.editingMessageId', null);
showNotification('success', 'Message updated!');
}
function deleteMessage(messageId) {
const messages = state.chat.messages.filter(m => m.id !== messageId);
updateState('chat.messages', messages);
showNotification('success', 'Message deleted!');
}
Rendering Messages with Controls
function renderMessages() {
const container = document.getElementById('message-list');
const { messages } = state.chat;
if (messages.length === 0) {
container.innerHTML = '<p class="empty-message">No messages yet</p>';
return;
}
container.innerHTML = messages.map(message => {
const isEditing = state.ui.editingMessageId === message.id;
const time = new Date(message.timestamp).toLocaleTimeString();
return `
<div class="message-item" data-id="${message.id}">
<div class="message-header">
<span class="message-username">${message.username}</span>
<span class="message-time">${time}</span>
${message.edited ? '<span class="edited-label">(edited)</span>' : ''}
</div>
${isEditing ? `
<input type="text" class="edit-input" value="${message.text}">
<div class="edit-actions">
<button class="save-edit-btn">Save</button>
<button class="cancel-edit-btn">Cancel</button>
</div>
` : `
<div class="message-text">${message.text}</div>
<div class="message-actions">
<button class="edit-btn" aria-label="Edit">Edit</button>
<button class="delete-btn" aria-label="Delete">Delete</button>
</div>
`}
</div>
`;
}).join('');
}
Part 3: localStorage Persistence
Saving State
function saveState() {
try {
localStorage.setItem('chatMessages', JSON.stringify(state.chat));
} catch (e) {
console.warn('Could not save to localStorage:', e);
}
}
Loading State
function loadState() {
try {
const saved = localStorage.getItem('chatMessages');
if (saved) {
state.chat = JSON.parse(saved);
}
} catch (e) {
console.warn('Could not load from localStorage:', e);
}
}
Initialize with Saved Data
function init() {
loadState();
render();
setupEventListeners();
}
Now messages persist across page refreshes!
Part 4: Form Handling
The Settings Form
<section id="settings" class="hidden">
<h2>User Settings</h2>
<form id="settings-form">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
required
minlength="2"
placeholder="Your username"
>
<span class="error-message"></span>
</div>
<div class="form-group">
<label for="user-email">Email</label>
<input
type="email"
id="user-email"
name="userEmail"
required
placeholder="your@email.com"
>
<span class="error-message"></span>
</div>
<div class="form-actions">
<button type="button" class="secondary" id="back-to-chat">
Back to Chat
</button>
<button type="submit" class="primary">
Save Settings
</button>
</div>
</form>
</section>
Form Validation
function validateForm(formData) {
const errors = {};
// Username validation
if (!formData.username.trim()) {
errors.username = 'Username is required';
} else if (formData.username.length < 2) {
errors.username = 'Username must be at least 2 characters';
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.userEmail.trim()) {
errors.userEmail = 'Email is required';
} else if (!emailRegex.test(formData.userEmail)) {
errors.userEmail = 'Please enter a valid email';
}
return {
isValid: Object.keys(errors).length === 0,
errors
};
}
Form Submission
function setupSettingsForm() {
const form = document.getElementById('settings-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
// Gather form data
const formData = {
username: form.username.value,
userEmail: form.userEmail.value
};
// Validate
const { isValid, errors } = validateForm(formData);
// Clear previous errors
form.querySelectorAll('.error-message').forEach(el => {
el.textContent = '';
});
form.querySelectorAll('input').forEach(el => {
el.classList.remove('invalid');
});
if (!isValid) {
// Show errors
Object.entries(errors).forEach(([field, message]) => {
const input = form.querySelector(`[name="${field}"]`);
const errorEl = input.nextElementSibling;
input.classList.add('invalid');
errorEl.textContent = message;
});
return;
}
// Save settings
saveSettings(formData);
});
}
Real-time Validation
function setupRealTimeValidation() {
const form = document.getElementById('settings-form');
form.querySelectorAll('input').forEach(input => {
input.addEventListener('blur', () => {
validateField(input);
});
input.addEventListener('input', () => {
// Clear error when user starts typing
if (input.classList.contains('invalid')) {
input.classList.remove('invalid');
input.nextElementSibling.textContent = '';
}
});
});
}
function validateField(input) {
const formData = {
[input.name]: input.value
};
// Partial validation for single field
const { errors } = validateForm({
username: '',
userEmail: '',
...formData
});
const errorEl = input.nextElementSibling;
if (errors[input.name]) {
input.classList.add('invalid');
errorEl.textContent = errors[input.name];
}
}
Part 5: Visual Feedback
CSS Transitions
/* Smooth transitions for interactive elements */
.send-button,
.edit-btn,
.delete-btn {
transition: transform 0.1s, background-color 0.2s;
}
.send-button:active,
.edit-btn:active {
transform: scale(0.95);
}
/* Message item animations */
.message-item {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Form validation states */
input.invalid {
border-color: #ef4444;
background-color: #fef2f2;
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
min-height: 1.25rem;
}
Toast Notifications
function showNotification(type, message) {
updateState('ui.notification', { type, message });
// Auto-dismiss after 3 seconds
setTimeout(() => {
if (state.ui.notification?.message === message) {
updateState('ui.notification', null);
}
}, 3000);
}
function renderNotification() {
let container = document.getElementById('notification');
if (!container) {
container = document.createElement('div');
container.id = 'notification';
document.body.appendChild(container);
}
const { notification } = state.ui;
if (!notification) {
container.className = 'notification hidden';
return;
}
container.className = `notification ${notification.type}`;
container.textContent = notification.message;
}
/* Toast notification styles */
.notification {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 1rem 1.5rem;
border-radius: 8px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: opacity 0.3s, transform 0.3s;
}
.notification.hidden {
opacity: 0;
transform: translateY(1rem);
pointer-events: none;
}
.notification.success {
background-color: #22c55e;
color: white;
}
.notification.error {
background-color: #ef4444;
color: white;
}
Loading States
function setLoading(isLoading) {
const submitBtn = document.querySelector('#settings-form button[type="submit"]');
if (isLoading) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Saving...';
} else {
submitBtn.disabled = false;
submitBtn.textContent = 'Save Settings';
}
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
Part 6: Panel Navigation
Managing Views
function showPanel(panelName) {
// Hide all panels
document.querySelectorAll('section').forEach(section => {
section.classList.add('hidden');
});
// Show requested panel
const panel = document.getElementById(panelName);
if (panel) {
panel.classList.remove('hidden');
}
updateState('ui.activePanel', panelName);
}
// Navigation functions
function goToSettings() {
showPanel('settings');
}
function goToChat() {
showPanel('chat');
}
function goToConfirmation() {
showPanel('confirmation');
}
Save Settings Flow
async function saveSettings(userData) {
setLoading(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
// Update state with user info
updateState('chat.username', userData.username);
updateState('chat.userEmail', userData.userEmail);
// Show confirmation
renderConfirmation();
showPanel('confirmation');
setLoading(false);
}
function renderConfirmation() {
const container = document.getElementById('confirmation');
const { username, messages } = state.chat;
container.innerHTML = `
<div class="confirmation-content">
<div class="success-icon">✓</div>
<h2>Settings Saved!</h2>
<p>Welcome, ${username}!</p>
<div class="chat-summary">
<p>You have ${messages.length} message${messages.length !== 1 ? 's' : ''} in your chat history.</p>
</div>
<button onclick="goToChat()" class="primary">
Back to Chat
</button>
</div>
`;
}
function clearChat() {
// Reset state
state.chat = {
messages: [],
username: state.chat.username,
userEmail: state.chat.userEmail
};
localStorage.removeItem('chatMessages');
render();
showPanel('chat');
showNotification('success', 'Chat cleared!');
}
Patterns You've Discovered
Pattern 1: Centralized State
const state = { /* all app state */ };
function updateState(path, value) { /* update + render */ }
React calls this: Component state and useState hook
Pattern 2: Declarative Rendering
function render() {
// Generate UI from current state
container.innerHTML = state.chat.messages.map(msg => `...`).join('');
}
React calls this: JSX and the virtual DOM
Pattern 3: Unidirectional Data Flow
User Action → Update State → Re-render UI
React enforces this pattern with props down, events up
Pattern 4: Component-Based Thinking
Each section (message input, message list, settings) is conceptually a "component" with:
- Its own render function
- Its own event handlers
- Data passed from state
React makes this explicit with function components
Exercise 1: Implement Edit and Delete Controls
Add edit/delete buttons to messages:
- Update the message item HTML template
- Add event listeners for edit and delete buttons
- Implement
editMessage(messageId, newText)anddeleteMessage(messageId) - Handle the editing state UI
Solution
function startEditing(messageId) {
updateState('ui.editingMessageId', messageId);
}
function cancelEditing() {
updateState('ui.editingMessageId', null);
}
function editMessage(messageId, newText) {
const messages = [...state.chat.messages];
const index = messages.findIndex(m => m.id === messageId);
if (index === -1) return;
messages[index].text = newText.trim();
messages[index].edited = true;
updateState('chat.messages', messages);
updateState('ui.editingMessageId', null);
}
// In setupEventListeners:
document.getElementById('message-list').addEventListener('click', (e) => {
const messageItem = e.target.closest('.message-item');
if (!messageItem) return;
const messageId = messageItem.dataset.id;
if (e.target.classList.contains('edit-btn')) {
startEditing(messageId);
} else if (e.target.classList.contains('delete-btn')) {
deleteMessage(messageId);
} else if (e.target.classList.contains('save-edit-btn')) {
const input = messageItem.querySelector('.edit-input');
editMessage(messageId, input.value);
} else if (e.target.classList.contains('cancel-edit-btn')) {
cancelEditing();
}
});
Exercise 2: Add localStorage Persistence
Make messages survive page refresh:
- Implement
saveState()to save chat data to localStorage - Implement
loadState()to restore on page load - Call
saveState()after each state update - Call
loadState()ininit()
Exercise 3: Build the Settings Form
Create a working settings form:
- Add the form HTML (username, email fields)
- Implement
validateForm() - Handle form submission
- Show validation errors inline
Exercise 4: Add Notifications
Implement toast notifications:
- Create notification container in HTML
- Implement
showNotification(type, message) - Style success and error variants
- Auto-dismiss after 3 seconds
Complete File Structure
After Stage 2:
chat-interactive/
├── index.html (~120 lines)
├── styles.css (~250 lines)
└── app.js (~200 lines)
About 570 lines total — complexity is growing.
Key Takeaways
-
Centralize state — One source of truth, update functions, auto-render
-
Forms need validation — Both on submit and real-time
-
Feedback matters — Users need to know their actions worked
-
Patterns emerge — The code naturally wants to be a framework
-
localStorage is simple persistence — Good enough for client-side data
-
Complexity grows — We're ready for tools to help manage it
What's Next
You'll learn:
- Converting vanilla JS patterns to React components
- useState and useEffect hooks
- Component composition
- React Router for navigation
- How React solves the problems we encountered
You've completed Stage 2! You've built patterns that professional frameworks formalize. Stage 3 will show how React makes these patterns explicit and scalable.