vfunc.js

Tutorial: build a to-do app

A step-by-step guide for first-time users. You build a to-do app from three files, add saving and components, and put it on the internet. All you install is an editor and Python; there are no build tools. Each step takes about ten minutes.

Every step has an Open the finished step link. If you get stuck, compare it with your files. The code on this page is the same code the tests run.

Get ready

  1. Editor: install Visual Studio Code. Notepad works too, but colors help.
  2. Python: it runs a small server that shows your files in the browser. Type python --version in a terminal (PowerShell on Windows); if a version appears, you are ready.
    • If not, install it from python.org. On Windows, tick Add python.exe to PATH on the first screen of the installer.
  3. Folder: make a folder named todo (on the desktop, for example) and open it in VS Code with File → Open Folder.

Run the server inside the todo folder. In VS Code open Terminal → New Terminal, type the line below, then open http://localhost:8080/ in the browser. Press Ctrl+C in the terminal to stop it.

python -m http.server 8080

Step 1: the first screen

Make three files in the todo folder. First index.html, the frame of the page, which loads vfunc and your code.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; object-src 'none'; base-uri 'none'">
  <title>To-do — step 1</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc.tokens.css"
        integrity="sha384-QwsFKKFVXW8Lo7pML80GpQro2b0FlTfTrSjGN+YMCyx868Oa2bXlbYRGuUUbgiVw" crossorigin="anonymous">
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main class="todo">
    <h1 class="todo__title">Today's to-dos</h1>
    <div id="app"></div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc.js"
        integrity="sha384-HOwph430qtUZpsvBjI0IzL1lkEipwi7yZYM7X7z21/bLJsChFHRF9E5OvWde7RjH" crossorigin="anonymous"></script>
  <script src="./app.js"></script>
</body>
</html>

You keep one style.css until the end, so copy it once and move on. Colors and spacing are all var(--vf-…) tokens, so they change with the theme.

/* The to-do app's styles. Colors, spacing and fonts use tokens (var(--vf-*)) only. */
*, *::before, *::after { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--vf-color-bg);
  color: var(--vf-color-text);
  font-family: var(--vf-font-body);
  font-size: var(--vf-font-size-md);
  line-height: var(--vf-line-height);
}

.todo { max-width: 560px; margin: 0 auto; padding: var(--vf-space-6) var(--vf-space-4); }
.todo__title { margin: 0 0 var(--vf-space-4); font-size: var(--vf-font-size-2xl); }
.todo__label { display: block; margin-bottom: var(--vf-space-1); font-weight: var(--vf-font-weight-strong); }
.todo__greeting { font-size: var(--vf-font-size-lg); }

.todo__form { display: flex; gap: var(--vf-space-2); margin-bottom: var(--vf-space-4); }
.todo__input {
  flex: 1;
  width: 100%;
  padding: var(--vf-space-2) var(--vf-space-3);
  border: 1px solid var(--vf-color-border-strong);
  border-radius: var(--vf-radius-md);
  background: var(--vf-color-surface);
  color: var(--vf-color-text);
  font: inherit;
}
.todo__button {
  padding: var(--vf-space-2) var(--vf-space-4);
  border: 0;
  border-radius: var(--vf-radius-md);
  background: var(--vf-color-primary);
  color: var(--vf-color-on-primary);
  font: inherit;
  cursor: pointer;
}
.todo__button:hover { background: var(--vf-color-primary-hover); }
.todo__input:focus-visible, .todo__button:focus-visible, .todo__remove:focus-visible {
  outline: none;
  box-shadow: var(--vf-focus-ring);
}

.todo__list { list-style: none; margin: 0; padding: 0; }
.todo__item {
  display: flex;
  align-items: center;
  gap: var(--vf-space-2);
  padding: var(--vf-space-2) var(--vf-space-3);
  margin-bottom: var(--vf-space-2);
  border: 1px solid var(--vf-color-border);
  border-radius: var(--vf-radius-md);
  background: var(--vf-color-surface);
}
.todo__text { flex: 1; }
.todo__item[data-state="done"] .todo__text { color: var(--vf-color-text-muted); text-decoration: line-through; }
.todo__remove {
  border: 0;
  background: transparent;
  color: var(--vf-color-text-muted);
  font: inherit;
  cursor: pointer;
}
.todo__remove:hover { color: var(--vf-color-danger-text); }
.todo__footer { margin: var(--vf-space-3) 0 0; color: var(--vf-color-text-muted); font-size: var(--vf-font-size-sm); }

app.js is the vfunc code.

// Step 1: one component. When the state changes, the screen is drawn again.
const hello = vf.vfunc({
  state: { name: '' },
  render: (s) => vf.html`
    <label class="todo__label" for="name">Name</label>
    <input class="todo__input" id="name" data-action="rename" value="${s.name}"
           placeholder="Type your name" autocomplete="off">
    <p class="todo__greeting" data-ref="greeting">Hello, ${s.name || 'guest'}!</p>`,
  delegates: [{
    selector: '[data-action="rename"]',
    eventType: 'input',
    onEvent: (e) => { e.sender.name = e.target.value; }
  }]
});

hello.mount('#app');
The greeting changes as you type a name
The greeting changes as you type a name

Open the finished step

Step 2: add to-dos

Now take to-dos instead of a name. Replace app.js with the code below; index.html and style.css stay the same.

// Step 2: add to-dos. Put what was typed into the list (state.items) and the list is drawn again.
let nextId = 1;

const todo = vf.vfunc({
  state: { items: [] },
  render: (s) => vf.html`
    <form class="todo__form" data-action="add">
      <input class="todo__input" name="text" data-ref="input" aria-label="To-do"
             placeholder="What needs doing?" autocomplete="off" required>
      <button class="todo__button" type="submit">Add</button>
    </form>
    <ul class="todo__list">
      ${s.items.map((item) => vf.html`<li class="todo__item">${item.text}</li>`)}
    </ul>`,
  delegates: [{
    selector: '[data-action="add"]',
    eventType: 'submit',
    onEvent: (e) => {
      e.event.preventDefault();                   // keep the form from reloading the page
      const text = e.sender.refs.input.value.trim();
      if (!text) return;
      e.sender.items = e.sender.items.concat({ id: nextId++, text: text });
    }
  }]
});

todo.mount('#app');
The screen after adding two to-dos
The screen after adding two to-dos

Open the finished step

Step 3: mark as done and delete

Give each item a checkbox and a delete button, and show how many are left.

// Step 3: mark as done and delete. Buttons in the list are told apart by data-action; the item is found by data-id.
let nextId = 1;

const row = (item) => vf.html`
  <li class="todo__item" data-id="${item.id}" data-state="${item.done ? 'done' : 'open'}">
    <input type="checkbox" id="done-${item.id}" data-action="toggle" ${item.done ? 'checked' : ''}>
    <label class="todo__text" for="done-${item.id}">${item.text}</label>
    <button class="todo__remove" type="button" data-action="remove" aria-label="Delete">✕</button>
  </li>`;

// The data-id of the row the event happened in, as a number.
const idOf = (e) => Number(e.target.closest('[data-id]').getAttribute('data-id'));

const todo = vf.vfunc({
  state: { items: [] },
  render: (s) => {
    const left = s.items.filter((item) => !item.done).length;
    return vf.html`
      <form class="todo__form" data-action="add">
        <input class="todo__input" name="text" data-ref="input" aria-label="To-do"
               placeholder="What needs doing?" autocomplete="off" required>
        <button class="todo__button" type="submit">Add</button>
      </form>
      <ul class="todo__list">${s.items.map(row)}</ul>
      <p class="todo__footer"><strong data-ref="left">${left}</strong> left</p>`;
  },
  methods: {
    add(text) {
      this.items = this.items.concat({ id: nextId++, text: text, done: false });
    },
    toggle(id) {
      this.items = this.items.map((item) => (item.id === id ? { id: item.id, text: item.text, done: !item.done } : item));
    },
    remove(id) {
      this.items = this.items.filter((item) => item.id !== id);
    }
  },
  delegates: [
    {
      selector: '[data-action="add"]',
      eventType: 'submit',
      onEvent: (e) => {
        e.event.preventDefault();
        const text = e.sender.refs.input.value.trim();
        if (text) e.sender.add(text);
      }
    },
    { selector: '[data-action="toggle"]', eventType: 'change', onEvent: (e) => e.sender.toggle(idOf(e)) },
    { selector: '[data-action="remove"]', eventType: 'click', onEvent: (e) => e.sender.remove(idOf(e)) }
  ]
});

todo.mount('#app');
Finishing the first item crosses it out and leaves 2
Finishing the first item crosses it out and leaves 2

Open the finished step

Step 4: save the list

Right now the list disappears when you reload. Save it in the browser's storage (localStorage).

// Step 4: save. Every time the list changes it is saved in the browser (localStorage) and loaded when the page opens.
const KEY = 'vfunc-tutorial-todo';

// Saved values come from outside the code, so keep only the fields we need.
function load() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY)) || [];
    return saved.map((item) => ({ id: Number(item.id), text: String(item.text), done: item.done === true }));
  } catch (err) {
    return [];
  }
}

const saved = load();
let nextId = saved.reduce((max, item) => Math.max(max, item.id), 0) + 1;

const row = (item) => vf.html`
  <li class="todo__item" data-id="${item.id}" data-state="${item.done ? 'done' : 'open'}">
    <input type="checkbox" id="done-${item.id}" data-action="toggle" ${item.done ? 'checked' : ''}>
    <label class="todo__text" for="done-${item.id}">${item.text}</label>
    <button class="todo__remove" type="button" data-action="remove" aria-label="Delete">✕</button>
  </li>`;

const idOf = (e) => Number(e.target.closest('[data-id]').getAttribute('data-id'));

const todo = vf.vfunc({
  state: { items: saved },
  render: (s) => {
    const left = s.items.filter((item) => !item.done).length;
    return vf.html`
      <form class="todo__form" data-action="add">
        <input class="todo__input" name="text" data-ref="input" aria-label="To-do"
               placeholder="What needs doing?" autocomplete="off" required>
        <button class="todo__button" type="submit">Add</button>
      </form>
      <ul class="todo__list">${s.items.map(row)}</ul>
      <p class="todo__footer"><strong data-ref="left">${left}</strong> left</p>`;
  },
  // Called after every render. Save here.
  onUpdate: (inst) => {
    localStorage.setItem(KEY, JSON.stringify(inst.items));
  },
  methods: {
    add(text) {
      this.items = this.items.concat({ id: nextId++, text: text, done: false });
    },
    toggle(id) {
      this.items = this.items.map((item) => (item.id === id ? { id: item.id, text: item.text, done: !item.done } : item));
    },
    remove(id) {
      this.items = this.items.filter((item) => item.id !== id);
    }
  },
  delegates: [
    {
      selector: '[data-action="add"]',
      eventType: 'submit',
      onEvent: (e) => {
        e.event.preventDefault();
        const text = e.sender.refs.input.value.trim();
        if (text) e.sender.add(text);
      }
    },
    { selector: '[data-action="toggle"]', eventType: 'change', onEvent: (e) => e.sender.toggle(idOf(e)) },
    { selector: '[data-action="remove"]', eventType: 'click', onEvent: (e) => e.sender.remove(idOf(e)) }
  ]
});

todo.mount('#app');
The list and the done mark are still there after a reload
The list and the done mark are still there after a reload

Open the finished step

Step 5: use components

vfunc comes with components (vfunc-ui) such as buttons, an empty screen, notifications and confirm dialogs. Use them only where they help. Add two lines to index.html (vfunc-ui.css and vfunc-ui.js).

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; object-src 'none'; base-uri 'none'">
  <title>To-do — step 5</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc.tokens.css"
        integrity="sha384-QwsFKKFVXW8Lo7pML80GpQro2b0FlTfTrSjGN+YMCyx868Oa2bXlbYRGuUUbgiVw" crossorigin="anonymous">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc-ui.css"
        integrity="sha384-lb4q+RHHylsEIsyBQuDJbSZAAocK3fj1H4bBQFf5S7UoMLfN2j3L/G9L5AYE6uIA" crossorigin="anonymous">
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main class="todo">
    <h1 class="todo__title">Today's to-dos</h1>
    <div id="app"></div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc.js"
        integrity="sha384-HOwph430qtUZpsvBjI0IzL1lkEipwi7yZYM7X7z21/bLJsChFHRF9E5OvWde7RjH" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc-ui.js"
        integrity="sha384-I+GaVCl8OOTjHk4zMfuH/l8vwopR07O0vlZUgVvqmwSJwKjtG6+u8Yc3KkZBUm3G" crossorigin="anonymous"></script>
  <script src="./app.js"></script>
</body>
</html>

app.js:

// Step 5: use components (vfunc-ui). Buttons and the empty screen are vs* (markup); the toast and the confirm dialog are vf* (instances).
const KEY = 'vfunc-tutorial-todo';

function load() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY)) || [];
    return saved.map((item) => ({ id: Number(item.id), text: String(item.text), done: item.done === true }));
  } catch (err) {
    return [];
  }
}

const saved = load();
let nextId = saved.reduce((max, item) => Math.max(max, item.id), 0) + 1;
let toast;   // the notification area: one per page (made at the very end)

const row = (item) => vf.html`
  <li class="todo__item" data-id="${item.id}" data-state="${item.done ? 'done' : 'open'}">
    <input type="checkbox" id="done-${item.id}" data-action="toggle" ${item.done ? 'checked' : ''}>
    <label class="todo__text" for="done-${item.id}">${item.text}</label>
    ${vf.vsButton({ label: 'Delete', variant: 'ghost', size: 'sm', action: 'remove' })}
  </li>`;

const idOf = (e) => Number(e.target.closest('[data-id]').getAttribute('data-id'));

const todo = vf.vfunc({
  state: { items: saved },
  render: (s) => {
    const left = s.items.filter((item) => !item.done).length;
    return vf.html`
      <form class="todo__form" data-action="add">
        <input class="todo__input" name="text" data-ref="input" aria-label="To-do"
               placeholder="What needs doing?" autocomplete="off" required>
        ${vf.vsButton({ label: 'Add', type: 'submit', variant: 'primary' })}
      </form>
      ${s.items.length
        ? vf.html`<ul class="todo__list">${s.items.map(row)}</ul>
                  <p class="todo__footer"><strong data-ref="left">${left}</strong> left</p>`
        : vf.vsEmptyState({ title: 'Nothing to do', description: 'Type a to-do above and press Add.' })}`;
  },
  onUpdate: (inst) => {
    localStorage.setItem(KEY, JSON.stringify(inst.items));
  },
  methods: {
    add(text) {
      this.items = this.items.concat({ id: nextId++, text: text, done: false });
      toast.show({ message: 'Added: ' + text, variant: 'success' });
    },
    toggle(id) {
      this.items = this.items.map((item) => (item.id === id ? { id: item.id, text: item.text, done: !item.done } : item));
    },
    // Ask once before deleting. open() waits for the answer (true or false).
    async remove(id) {
      const item = this.items.find((x) => x.id === id);
      const dialog = vf.vfConfirm({ title: 'Delete this?', message: item.text, variant: 'danger', confirmLabel: 'Delete' });
      const ok = await dialog.open();
      dialog.destroy();
      if (!ok) return;
      this.items = this.items.filter((x) => x.id !== id);
      toast.show({ message: 'Deleted' });
    }
  },
  delegates: [
    {
      selector: '[data-action="add"]',
      eventType: 'submit',
      onEvent: (e) => {
        e.event.preventDefault();
        const text = e.sender.refs.input.value.trim();
        if (text) e.sender.add(text);
      }
    },
    { selector: '[data-action="toggle"]', eventType: 'change', onEvent: (e) => e.sender.toggle(idOf(e)) },
    { selector: '[data-action="remove"]', eventType: 'click', onEvent: (e) => e.sender.remove(idOf(e)) }
  ]
});

toast = vf.vfToast();
todo.mount('#app');
When the list is empty, a hint is shown
When the list is empty, a hint is shown
Adding shows a notification in the lower right
Adding shows a notification in the lower right
A dialog asks before deleting. Escape or Cancel keeps the item
A dialog asks before deleting. Escape or Cancel keeps the item
Switch the computer to dark mode and the tokens turn dark. The code stays the same
Switch the computer to dark mode and the tokens turn dark. The code stays the same

Open the finished step

Step 6: put it on the internet

Switch to the production files. The development vfunc.js carries its warning texts and is larger. Before publishing, change the scripts in index.html to the min files. Here is step 5's index.html for production (the integrity values changed with the files).

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; object-src 'none'; base-uri 'none'">
  <title>To-do — step 5</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc.tokens.css"
        integrity="sha384-QwsFKKFVXW8Lo7pML80GpQro2b0FlTfTrSjGN+YMCyx868Oa2bXlbYRGuUUbgiVw" crossorigin="anonymous">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc-ui.css"
        integrity="sha384-lb4q+RHHylsEIsyBQuDJbSZAAocK3fj1H4bBQFf5S7UoMLfN2j3L/G9L5AYE6uIA" crossorigin="anonymous">
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main class="todo">
    <h1 class="todo__title">Today's to-dos</h1>
    <div id="app"></div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc.min.js"
        integrity="sha384-yTR0o8US3nk+D1XIBAsv1O/lMFA5tV3V021xPo8yxyqnBNSxi1rqINVvBOykAEkK" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc-ui.min.js"
        integrity="sha384-bjDcJm9zuGIIvCwh/qrHrukBRpbwhH5dnnDDM7Z0vByWS9N8bQWs3RfqseMNajiy" crossorigin="anonymous"></script>
  <script src="./app.js"></script>
</body>
</html>

Publish with GitHub Pages (free, you only need an account):

  1. Sign up at github.com and press + → New repository in the top right. Give it a name (for example todo) and make it Public.
  2. On the repository page press Add file → Upload files, drop index.html, style.css and app.js, and press Commit changes.
  3. In Settings → Pages, set Source to Deploy from a branch and Branch to main and / (root), then press Save.
  4. After a minute or two the address (https://<your-name>.github.io/todo/) appears at the top of the same screen. Send that address to anyone.

When you are stuck

What you seeWhat to check
The page is emptyF12 → red text in Console. vf is not defined means the vfunc address in index.html or the <script> order (vfunc before app.js)
The address starts with file://You opened the file by double-clicking it. Start the server and open http://localhost:8080/
Refused to load or a Content-Security-Policy errorThe security line in index.html has https://cdn.jsdelivr.net, and the file addresses are right
An integrity errorThe version and the integrity value do not match. Copy the code on this page again
Pressing Add reloads the pagee.event.preventDefault() is missing
onclick="…" does nothingThe security setting blocks it. Use data-action and delegates
The list is gone after a reloadA private window, or the browser blocks storage. Check onUpdate in step 4 too

If it still does not work, read the FAQ or ask in GitHub Discussions. Include the console error text and you will get an answer faster.

Next