Stage 2: Interactive UI
Enhancing the Lemonade Stand 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
- Persist data with localStorage
- Recognize patterns that frameworks solve
Time: 3-4 hours (reading + building)
Introduction
Stage 1 built a working lemonade stand. But it has limitations:
- Refreshing the page loses the order
- No way to adjust quantities (only add)
- No feedback when actions happen
- No customer information for the order
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 Order │
│ • Quantity controls (+/−) │
│ • Remove items entirely │
│ • Persistence in localStorage │
├─────────────────────────────────────────────────────────────┤
│ NEW: Checkout Flow │
│ • Customer information form │
│ • Form validation │
│ • Order 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 orderItems = [];
But as features grow, you end up with:
let orderItems = [];
let customerName = '';
let customerEmail = '';
let isCheckingOut = false;
let checkoutError = 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 = {
order: {
items: [], // Array of { itemId, quantity }
customerName: '',
customerEmail: ''
},
ui: {
isCheckingOut: false,
notification: null, // { type: 'success'|'error', message: string }
activePanel: 'menu' // 'menu' | 'checkout' | 'confirmation'
}
};
State Update Pattern
Never modify state directly. Use functions:
// ❌ Direct mutation (hard to track, no re-render)
state.order.items.push(item);
// ✅ 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() {
renderMenu();
renderOrder();
renderCheckout();
renderNotification();
}
This is the pattern React uses — state changes trigger re-renders.
Part 2: Enhanced Order Management
Order Item Structure
Instead of duplicating items, track quantity:
// Stage 1: Multiple entries for same item
orderItems = [
{ id: 'classic', name: 'Classic', price: 2.50 },
{ id: 'classic', name: 'Classic', price: 2.50 }, // Duplicate!
];
// Stage 2: Track quantity
state.order.items = [
{ itemId: 'classic', quantity: 2 }
];
Add to Order (Improved)
function addToOrder(itemId) {
const items = [...state.order.items];
const existing = items.find(item => item.itemId === itemId);
if (existing) {
existing.quantity++;
} else {
items.push({ itemId, quantity: 1 });
}
updateState('order.items', items);
showNotification('success', 'Added to order!');
}
Quantity Controls
function updateQuantity(itemId, delta) {
const items = [...state.order.items];
const existing = items.find(item => item.itemId === itemId);
if (!existing) return;
existing.quantity += delta;
if (existing.quantity <= 0) {
// Remove item entirely
const index = items.indexOf(existing);
items.splice(index, 1);
}
updateState('order.items', items);
}
function removeFromOrder(itemId) {
const items = state.order.items.filter(item => item.itemId !== itemId);
updateState('order.items', items);
}
Rendering with Quantity Controls
function renderOrder() {
const container = document.getElementById('order-items');
const { items } = state.order;
if (items.length === 0) {
container.innerHTML = '<p class="empty-message">No items yet</p>';
updateTotal(0);
return;
}
container.innerHTML = items.map(orderItem => {
const menuItem = menuItems.find(m => m.id === orderItem.itemId);
const subtotal = menuItem.price * orderItem.quantity;
return `
<div class="order-item" data-id="${orderItem.itemId}">
<div class="order-item-info">
<span class="order-item-name">${menuItem.name}</span>
<span class="order-item-subtotal">$${subtotal.toFixed(2)}</span>
</div>
<div class="quantity-controls">
<button class="qty-btn minus" aria-label="Decrease">−</button>
<span class="quantity">${orderItem.quantity}</span>
<button class="qty-btn plus" aria-label="Increase">+</button>
</div>
<button class="remove-btn" aria-label="Remove">×</button>
</div>
`;
}).join('');
// Calculate total
const total = items.reduce((sum, orderItem) => {
const menuItem = menuItems.find(m => m.id === orderItem.itemId);
return sum + (menuItem.price * orderItem.quantity);
}, 0);
updateTotal(total);
}
Part 3: localStorage Persistence
Saving State
function saveState() {
try {
localStorage.setItem('lemonadeOrder', JSON.stringify(state.order));
} catch (e) {
console.warn('Could not save to localStorage:', e);
}
}
Loading State
function loadState() {
try {
const saved = localStorage.getItem('lemonadeOrder');
if (saved) {
state.order = JSON.parse(saved);
}
} catch (e) {
console.warn('Could not load from localStorage:', e);
}
}
Initialize with Saved Data
function init() {
loadState();
render();
setupEventListeners();
}
Now orders persist across page refreshes!
Part 4: Form Handling
The Checkout Form
<section id="checkout" class="hidden">
<h2>Checkout</h2>
<form id="checkout-form">
<div class="form-group">
<label for="customer-name">Name</label>
<input
type="text"
id="customer-name"
name="customerName"
required
minlength="2"
placeholder="Your name"
>
<span class="error-message"></span>
</div>
<div class="form-group">
<label for="customer-email">Email</label>
<input
type="email"
id="customer-email"
name="customerEmail"
required
placeholder="your@email.com"
>
<span class="error-message"></span>
</div>
<div class="form-actions">
<button type="button" class="secondary" id="back-to-menu">
Back to Menu
</button>
<button type="submit" class="primary">
Complete Order
</button>
</div>
</form>
</section>
Form Validation
function validateForm(formData) {
const errors = {};
// Name validation
if (!formData.customerName.trim()) {
errors.customerName = 'Name is required';
} else if (formData.customerName.length < 2) {
errors.customerName = 'Name must be at least 2 characters';
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.customerEmail.trim()) {
errors.customerEmail = 'Email is required';
} else if (!emailRegex.test(formData.customerEmail)) {
errors.customerEmail = 'Please enter a valid email';
}
return {
isValid: Object.keys(errors).length === 0,
errors
};
}
Form Submission
function setupCheckoutForm() {
const form = document.getElementById('checkout-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
// Gather form data
const formData = {
customerName: form.customerName.value,
customerEmail: form.customerEmail.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;
}
// Process order
completeOrder(formData);
});
}
Real-time Validation
function setupRealTimeValidation() {
const form = document.getElementById('checkout-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({
customerName: '',
customerEmail: '',
...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 */
.add-button,
.qty-btn,
.remove-btn {
transition: transform 0.1s, background-color 0.2s;
}
.add-button:active,
.qty-btn:active {
transform: scale(0.95);
}
/* Order item animations */
.order-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('#checkout-form button[type="submit"]');
if (isLoading) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Processing...';
} else {
submitBtn.disabled = false;
submitBtn.textContent = 'Complete Order';
}
}
.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 goToCheckout() {
if (state.order.items.length === 0) {
showNotification('error', 'Add some items first!');
return;
}
showPanel('checkout');
}
function goToMenu() {
showPanel('menu');
}
function goToConfirmation() {
showPanel('confirmation');
}
Complete Order Flow
async function completeOrder(customerData) {
setLoading(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
// Update state with customer info
updateState('order.customerName', customerData.customerName);
updateState('order.customerEmail', customerData.customerEmail);
// Generate order number
const orderNumber = Math.floor(Math.random() * 10000);
// Show confirmation
renderConfirmation(orderNumber);
showPanel('confirmation');
// Clear order from storage
localStorage.removeItem('lemonadeOrder');
setLoading(false);
}
function renderConfirmation(orderNumber) {
const container = document.getElementById('confirmation');
const { items, customerName } = state.order;
const total = items.reduce((sum, orderItem) => {
const menuItem = menuItems.find(m => m.id === orderItem.itemId);
return sum + (menuItem.price * orderItem.quantity);
}, 0);
container.innerHTML = `
<div class="confirmation-content">
<div class="success-icon">✓</div>
<h2>Order Confirmed!</h2>
<p>Thank you, ${customerName}!</p>
<p class="order-number">Order #${orderNumber}</p>
<div class="order-summary">
${items.map(orderItem => {
const menuItem = menuItems.find(m => m.id === orderItem.itemId);
return `<div>${menuItem.name} × ${orderItem.quantity}</div>`;
}).join('')}
<div class="total">Total: $${total.toFixed(2)}</div>
</div>
<button onclick="startNewOrder()" class="primary">
Start New Order
</button>
</div>
`;
}
function startNewOrder() {
// Reset state
state.order = {
items: [],
customerName: '',
customerEmail: ''
};
render();
showPanel('menu');
}
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.items.map(item => `...`).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 (menu, order, checkout) 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 Quantity Controls
Add +/− buttons to order items:
- Update the order item HTML template
- Add event listeners for quantity buttons
- Implement
updateQuantity(itemId, delta) - Handle edge case: quantity reaches 0
Solution
function updateQuantity(itemId, delta) {
const items = [...state.order.items];
const index = items.findIndex(item => item.itemId === itemId);
if (index === -1) return;
items[index].quantity += delta;
if (items[index].quantity <= 0) {
items.splice(index, 1);
}
updateState('order.items', items);
}
// In setupEventListeners:
document.getElementById('order-items').addEventListener('click', (e) => {
const orderItem = e.target.closest('.order-item');
if (!orderItem) return;
const itemId = orderItem.dataset.id;
if (e.target.classList.contains('plus')) {
updateQuantity(itemId, 1);
} else if (e.target.classList.contains('minus')) {
updateQuantity(itemId, -1);
} else if (e.target.classList.contains('remove-btn')) {
removeFromOrder(itemId);
}
});
Exercise 2: Add localStorage Persistence
Make orders survive page refresh:
- Implement
saveState()to save order to localStorage - Implement
loadState()to restore on page load - Call
saveState()after each state update - Call
loadState()ininit()
Exercise 3: Build the Checkout Form
Create a working checkout form:
- Add the form HTML (name, 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:
lemonade-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.