Series: Pure Node.js — Zero Dependencies

Post #7: Vanilla JS Charts & Dynamic UI

📅 March 2026 ⏱ 10 min read 🏷 Vanilla JS, Canvas, Fetch API, DOM

📚 Pure Node.js Series — Zero Dependencies

#1: Build an HTTP Server From Scratch #2: Manual Routing — No Express Needed #3: Serving Static Files (HTML, CSS, JS) #4: Handling Forms & POST Requests #5: JSON Files as a Database #6: HTML Templating with String Interpolation → #7: Vanilla JS Charts & Dynamic UI (you are here) #8: WebSockets From Scratch #9: Chat Rooms & Broadcast Messages #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving #12: Deploy Pure Node.js to a VPS

Fetch Data from the Node.js API

The browser's built-in fetch() API lets us call our Node.js server without any libraries. Let's load our todos and render them dynamically:

// static/dashboard.js

async function loadStats() {
  const res = await fetch('/api/todos');
  const { todos } = await res.json();

  const total = todos.length;
  const done = todos.filter(t => t.done).length;
  const pending = total - done;

  // Update stat cards
  document.getElementById('stat-total').textContent = total;
  document.getElementById('stat-done').textContent = done;
  document.getElementById('stat-pending').textContent = pending;

  // Draw the bar chart
  drawBarChart(document.getElementById('chart'), [
    { label: 'Total', value: total, color: '#ff6b35' },
    { label: 'Done', value: done, color: '#10b981' },
    { label: 'Pending', value: pending, color: '#f7931e' }
  ]);
}

// Run on page load
loadStats();

Drawing a Bar Chart on HTML5 Canvas

The <canvas> element gives us a 2D drawing API. No Chart.js needed:

function drawBarChart(canvas, data) {
  const ctx = canvas.getContext('2d');
  const W = canvas.width;
  const H = canvas.height;
  const padding = 40;
  const barWidth = (W - padding * 2) / data.length - 10;
  const maxValue = Math.max(...data.map(d => d.value), 1);

  // Clear canvas
  ctx.clearRect(0, 0, W, H);

  // Background
  ctx.fillStyle = '#1a1a1a';
  ctx.fillRect(0, 0, W, H);

  // Draw each bar
  data.forEach((item, i) => {
    const barH = ((item.value / maxValue) * (H - padding * 2));
    const x = padding + i * (barWidth + 10);
    const y = H - padding - barH;

    // Bar
    ctx.fillStyle = item.color;
    ctx.fillRect(x, y, barWidth, barH);

    // Value label on top
    ctx.fillStyle = '#e0e0e0';
    ctx.font = '14px Segoe UI';
    ctx.textAlign = 'center';
    ctx.fillText(item.value, x + barWidth / 2, y - 8);

    // Category label at bottom
    ctx.fillStyle = '#b0b0b0';
    ctx.font = '12px Segoe UI';
    ctx.fillText(item.label, x + barWidth / 2, H - 10);
  });

  // Y-axis line
  ctx.strokeStyle = 'rgba(255,255,255,0.1)';
  ctx.beginPath();
  ctx.moveTo(padding, padding);
  ctx.lineTo(padding, H - padding);
  ctx.stroke();
}

Rendering a Dynamic Todo List

function renderTodos(todos) {
  const list = document.getElementById('todo-list');
  list.innerHTML = '';

  if (todos.length === 0) {
    list.innerHTML = '<li class="empty">No todos yet!</li>';
    return;
  }

  todos.forEach(todo => {
    const li = document.createElement('li');
    li.className = `todo-item${todo.done ? ' done' : ''}`;

    const span = document.createElement('span');
    span.textContent = todo.title; // textContent is safe — no XSS risk

    const toggleBtn = document.createElement('button');
    toggleBtn.textContent = todo.done ? 'Undo' : '✓';
    toggleBtn.addEventListener('click', () => toggleTodo(todo.id));

    const deleteBtn = document.createElement('button');
    deleteBtn.textContent = '✕';
    deleteBtn.className = 'danger';
    deleteBtn.addEventListener('click', () => deleteTodo(todo.id));

    li.append(span, toggleBtn, deleteBtn);
    list.appendChild(li);
  });
}

async function toggleTodo(id) {
  await fetch(`/todos/${id}/toggle`, { method: 'POST' });
  loadStats(); // refresh
}

async function deleteTodo(id) {
  await fetch(`/todos/${id}/delete`, { method: 'POST' });
  loadStats(); // refresh
}
textContent vs innerHTML: Always use textContent to set user data in the DOM — it treats the value as plain text, not HTML. Using innerHTML with user data creates XSS vulnerabilities.

Adding a Todo Without Page Reload

document.getElementById('add-form')
  .addEventListener('submit', async (e) => {
    e.preventDefault();

    const input = document.getElementById('todo-input');
    const title = input.value.trim();
    if (!title) return;

    const res = await fetch('/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title })
    });

    if (res.ok) {
      input.value = '';
      loadStats(); // refresh the list + chart
    } else {
      const err = await res.json();
      alert(err.error);
    }
  });

CSS Animations Without Libraries

/* static/style.css */

/* Fade in new items */
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(-10px); }
  to   { opacity: 1; transform: translateY(0); }
}

.todo-item {
  animation: fadeIn 0.2s ease-out;
}

/* Progress bar */
.progress-bar {
  height: 8px;
  background: rgba(255,255,255,0.1);
  border-radius: 4px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #ff6b35, #10b981);
  transition: width 0.5s ease;
}
// Update progress bar
function updateProgress(done, total) {
  const pct = total ? (done / total) * 100 : 0;
  document.querySelector('.progress-fill').style.width = pct + '%';
}
Key takeaways:
  • Built-in fetch() — no axios, no xhr wrappers needed
  • HTML5 Canvas 2D API — full chart drawing, no Chart.js required
  • Use textContent not innerHTML for user data
  • DOM manipulation is powerful without React or Vue
  • CSS @keyframes + transition — animations without JavaScript libraries

What's Next

Module 2 is complete! In Post #8, we start Module 3 — the real-time Chat app. We'll implement WebSockets from scratch using Node's net module and raw TCP connections.

Building along? Share on X/Twitter or GitHub.