Skip to main content

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:

  1. Update the message item HTML template
  2. Add event listeners for edit and delete buttons
  3. Implement editMessage(messageId, newText) and deleteMessage(messageId)
  4. 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:

  1. Implement saveState() to save chat data to localStorage
  2. Implement loadState() to restore on page load
  3. Call saveState() after each state update
  4. Call loadState() in init()

Exercise 3: Build the Settings Form

Create a working settings form:

  1. Add the form HTML (username, email fields)
  2. Implement validateForm()
  3. Handle form submission
  4. Show validation errors inline

Exercise 4: Add Notifications

Implement toast notifications:

  1. Create notification container in HTML
  2. Implement showNotification(type, message)
  3. Style success and error variants
  4. 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

  1. Centralize state — One source of truth, update functions, auto-render

  2. Forms need validation — Both on submit and real-time

  3. Feedback matters — Users need to know their actions worked

  4. Patterns emerge — The code naturally wants to be a framework

  5. localStorage is simple persistence — Good enough for client-side data

  6. Complexity grows — We're ready for tools to help manage it


What's Next

Stage 3: React SPA

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.