Skip to main content

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:

  1. Update the order item HTML template
  2. Add event listeners for quantity buttons
  3. Implement updateQuantity(itemId, delta)
  4. 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:

  1. Implement saveState() to save order 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 Checkout Form

Create a working checkout form:

  1. Add the form HTML (name, 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:

lemonade-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.