The Nómada Tasks board already draws itself, filters, sorts and responds to clicks. But Marta still cannot do the most elementary thing: create a task. The backlog is the one in data/backlog.js and that is that. Adding a task means a form, and a form is much more than a few <input>s: it is the point where data you do not control comes in, where everything arrives as text, where you have to decide what is validated in the browser and what in the model, and where accessibility stops being a detail and becomes essential —an error message that can only be seen in red does not exist for someone who cannot see the screen. In this lesson you will build the complete creation form, with two-layer validation, accessible messages, on-the-fly validation with debounce and correct focus management. It is the last piece of Module 6.

Contents

  1. The creation form: semantic HTML
  2. The field attributes
  3. Reading values: value, FormData and Object.fromEntries
  4. Everything arrives as text
  5. The submit event and preventDefault
  6. Native HTML5 validation
  7. Native validation versus JavaScript validation
  8. Business rules live in the model
  9. Showing errors accessibly
  10. On-the-fly validation with input and debounce
  11. Submitting, resetting and returning focus
  12. Nómada Tasks: view/form.js
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. The creation form: semantic HTML

We start with the markup, because a well-written form solves half the work before you touch any JavaScript:

<section aria-labelledby="new-task-title">
  <h2 id="new-task-title">New task</h2>

  <form id="new-task" class="form" novalidate>
    <p id="form-errors" class="errors" role="alert" hidden></p>

    <fieldset>
      <legend>Task details</legend>

      <div class="field">
        <label for="title">Title <span aria-hidden="true">*</span></label>
        <input type="text" id="title" name="title" required
               minlength="3" maxlength="80" autocomplete="off"
               aria-describedby="title-hint">
        <small id="title-hint" class="hint">Between 3 and 80 characters.</small>
      </div>

      <div class="field">
        <label for="assignee">Assignee</label>
        <select id="assignee" name="assignee">
          <option value="">Unassigned</option>
          <option value="Marta">Marta</option>
          <option value="Iván">Iván</option>
          <option value="Lucía">Lucía</option>
        </select>
      </div>

      <div class="field">
        <label for="priority">Priority</label>
        <select id="priority" name="priority">
          <option value="high">High</option>
          <option value="medium" selected>Medium</option>
          <option value="low">Low</option>
        </select>
      </div>

      <div class="field">
        <label for="estimatedHours">Estimated hours <span aria-hidden="true">*</span></label>
        <input type="number" id="estimatedHours" name="estimatedHours"
               required min="1" max="40" step="1" inputmode="numeric"
               aria-describedby="hours-hint">
        <small id="hours-hint" class="hint">Between 1 and 40 hours (rule R3).</small>
      </div>

      <div class="field">
        <label for="dueDate">Due date <span aria-hidden="true">*</span></label>
        <input type="date" id="dueDate" name="dueDate" required min="2026-09-20">
      </div>

      <div class="field">
        <label for="tags">Tags</label>
        <input type="text" id="tags" name="tags"
               pattern="[a-zA-Z0-9, -]*" aria-describedby="tags-hint">
        <small id="tags-hint" class="hint">Comma-separated: screen-printing, storeroom</small>
      </div>

      <div class="field">
        <label for="notes">Notes</label>
        <textarea id="notes" name="notes" rows="2" maxlength="200"></textarea>
      </div>
    </fieldset>

    <div class="actions">
      <button type="submit">Create task</button>
      <button type="reset">Clear</button>
    </div>
  </form>
</section>

The decisions that matter:

  • Every field has its <label for="…"> pointing at the control's id. This is not optional: it is what makes a screen reader announce "Title, text field, required" and what lets you activate the field by clicking the label. A placeholder is not a substitute for a label: it disappears when you type and many readers do not announce it.
  • name on every control. It is the key under which the data will appear in FormData. Without a name, the field simply is not submitted. And here we have made them match the property names of Task (title, estimatedHours, dueDate), which is going to save a lot of work in section 3.
  • <fieldset> with <legend> groups related fields and gives them a common name, which screen readers announce when entering the group. It is essential with radio groups and highly recommended in general.
  • aria-describedby connects the field with its hint text, so that it is read along with the field's name.
  • role="alert" on the error container: when you put text in it, screen readers will announce it immediately, without the user having to go looking for it.
  • novalidate on the <form> turns off the browser's error bubbles, but it does not turn off the validation API: we can still query checkValidity(). That is what will let us show the errors with our own design and with the accessibility we want. We will come back to it in section 6.

  1. The field attributes

Modern HTML has far more validation capability than people usually use:

Attribute Applies to What it does
required Almost all The field cannot be left empty
minlength / maxlength text, textarea, search Length of the text (maxlength also prevents typing more)
min / max number, date, range Minimum and maximum value
step number, date, range Allowed increment (step="1" = whole numbers only)
pattern text, tel, search Regular expression the value must match
type All Validates the format: email, url, number, date
autocomplete Almost all Browser suggestions (off for one-off fields)
inputmode Text and number Which keyboard a phone shows (numeric, decimal, tel)
readonly / disabled Almost all Read-only / disabled (disabled is not submitted)

Two details that are often forgotten:

  • type="number" does not prevent typing letters in every browser, but it does make input.value return an empty string if the content is not a valid number. It is behavior that surprises people: the field looks like it has text and value is empty.
  • disabled excludes the field from submission; readonly includes it. If you need to show a fixed value and have it arrive with the submission, use readonly.

  1. Reading values: value, FormData and Object.fromEntries

There are three levels, from most manual to most automatic.

Level 1: field by field.

const form = document.querySelector('#new-task');
const title = form.querySelector('#title').value;
const hours = form.querySelector('#estimatedHours').value;

It works, but every new field is two more lines and an id that can go stale.

Level 2: form.elements. Every form has a collection of its controls, accessible by name:

console.log(form.elements.title.value);          // by name
console.log(form.elements['estimatedHours'].value);
console.log(form.elements.length);               // number of controls

It is convenient for reaching a specific control without looking it up with querySelector.

Level 3: FormData + Object.fromEntries. This is the idiomatic form:

const data = new FormData(form);

// FormData is iterable: [name, value] pairs
for (const [key, value] of data) {
  console.log(key, '=', JSON.stringify(value));
}
// title = "Buy white screen-printing ink"
// assignee = "Lucía"
// priority = "high"
// estimatedHours = "4"
// dueDate = "2026-10-10"
// tags = "screen-printing, storeroom"
// notes = ""

// And with Object.fromEntries (04-05), a plain object in a single line:
const raw = Object.fromEntries(data);
console.log(raw);
// { title: 'Buy white screen-printing ink', assignee: 'Lucía', priority: 'high',
//   estimatedHours: '4', dueDate: '2026-10-10', tags: 'screen-printing, storeroom', notes: '' }

Here you collect the dividend of having written name="estimatedHours" instead of name="hours": the object's keys already match the properties the Task constructor expects.

Two limitations of Object.fromEntries worth knowing:

  • With repeated names, it keeps the last one. A group of checkboxes with the same name requires data.getAll('tags'), which returns an array with every value.
  • Unchecked checkboxes do not appear in FormData. They are not false: they are simply not there. You have to account for their absence with ?? false or query the checked property.

  1. Everything arrives as text

Look at the output above again: estimatedHours is '4', the string, not the number. It is the same boundary you already saw with dataset in 06-02 and the whole reason for lesson 01-07.

const raw = Object.fromEntries(new FormData(form));

console.log(typeof raw.estimatedHours);        // 'string'
console.log(raw.estimatedHours + 1);           // '41'  ← concatenation
console.log(raw.estimatedHours > 40);          // false  ← '4' > 40 compares badly

That last case is especially treacherous: '4' > 40 is converted to 4 > 40 and gives false by coincidence; but '9' > 40 also gives false, and '10' > '9' gives false because it compares strings. Any numeric validation over unconverted text is a time bomb.

The solution is an explicit boundary conversion function, with Number (never parseInt, which accepts '12abc' and returns 12):

/** Converts the raw form data to the model's types. */
function normalize(raw) {
  return {
    title: raw.title.trim(),
    assignee: raw.assignee === '' ? null : raw.assignee,               // R8
    priority: raw.priority,
    estimatedHours: Number(raw.estimatedHours),                        // '4' → 4
    dueDate: raw.dueDate,                                              // already 'yyyy-mm-dd'
    tags: raw.tags
      .split(',')
      .map((t) => t.trim().toLowerCase())
      .filter((t) => t !== ''),                                        // R9
    notes: raw.notes.trim()
  };
}

console.log(normalize(raw));
// { title: 'Buy white screen-printing ink', assignee: 'Lucía', priority: 'high',
//   estimatedHours: 4, dueDate: '2026-10-10',
//   tags: ['screen-printing', 'storeroom'], notes: '' }

A warning about Number with the empty string: Number('') is 0, not NaN. If a required numeric field is submitted empty and the model expects a number, 0 would pass the conversion and fail later with a confusing message. That is why the HTML's required and the model's validation complement each other.

And a note about type="date": its value is always a 'yyyy-mm-dd' string, exactly the ISO format Nómada Tasks uses. There are also input.valueAsNumber (milliseconds) and valueAsDate (a Date object), but for us the string is ideal, because it is what the model stores.

  1. The submit event and preventDefault

A form's submission is listened for on the <form>, not on the button:

form.addEventListener('submit', (event) => {
  event.preventDefault();      // ← without this, the page reloads and you lose everything
  // … process
});

Without preventDefault(), the browser does what it did in 1995: it serializes the form, navigates to the URL in the action attribute (or reloads the current one) and your whole application starts over. It is mistake number one with forms and it produces the baffling symptom of "the page flickers and nothing happens".

Useful details about submit:

  • It fires with Enter inside a text field, not only when the button is pressed. It is a convenience users expect: do not break it by listening for click on the button instead of submit on the form.
  • A <button> inside a form is type="submit" by default. A button that does something else needs an explicit type="button", or it will submit the form by accident.
  • event.submitter tells you which button caused the submission, useful when there are several ("Create" and "Create and duplicate").
  • type="reset" fires a reset event and empties the fields; it is cancelable too.

  1. Native HTML5 validation

The browser comes with a complete validation engine, and wasting it is a frequent mistake. Every control has:

const field = form.elements.estimatedHours;

console.log(field.validity);
// ValidityState { valueMissing: false, rangeOverflow: true, badInput: false, valid: false, … }

console.log(field.checkValidity());     // false · is it valid? (fires an 'invalid' event)
console.log(field.validationMessage);   // 'Value must be less than or equal to 40.'
console.log(field.willValidate);        // true · does it take part in validation?

The validity object has one flag per kind of failure, and this allows precise messages:

Flag Turns on when
valueMissing It is required and it is empty
typeMismatch It does not match the type (email, url)
patternMismatch It does not match the pattern
tooShort / tooLong It breaks minlength / maxlength
rangeUnderflow / rangeOverflow It breaks min / max
stepMismatch It does not fit the step
badInput The browser cannot interpret what was typed (letters in type="number")
customError You have called setCustomValidity
valid None of the above

At form level:

form.checkValidity();     // are ALL the fields valid? (without showing anything)
form.reportValidity();    // the same, but it also SHOWS the browser's bubbles

And setCustomValidity lets you add an error of your own to the native engine:

const date = form.elements.dueDate;

date.setCustomValidity('The due date cannot be earlier than today.');
console.log(date.validity.customError);   // true
console.log(date.checkValidity());        // false

date.setCustomValidity('');               // ← empty string = valid field again

Golden rule with setCustomValidity: it has to be cleared. If you set a message and do not delete it when the user corrects things, the field stays invalid forever and the form will never submit, with no clue as to why.

In CSS you can react to validity with pseudo-classes:

.field input:user-invalid,
.field select:user-invalid { border-color: var(--color-high); }

.field input:user-valid { border-color: var(--color-low); }

:user-invalid is preferable to :invalid because it only turns on after the user has interacted with the field. With plain :invalid, every required field shows up in red as soon as the page loads, before anybody has typed anything: a hostile experience.

  1. Native validation versus JavaScript validation

Aspect Native validation (HTML5) JavaScript validation
Effort One attribute Code to write and maintain
Works without JS Yes No
Control of the message Limited (browser's language) Total
Control of design and timing Scarce Total
Rules across several fields Cannot Yes
Business rules (uniqueness, quotas) Cannot Yes
Accessibility Good by default Has to be built

The correct answer is not to choose one: it is to use both, in layers.

  1. HTML layer: required, min, max, pattern, type. Cheap, works without JavaScript, guides the user while typing and makes phones show the right keyboard.
  2. View's JavaScript layer: your own messages, when to show them, accessibility, cross-field rules.
  3. Model layer: the real business rules, which have existed since Module 5 and which are not duplicated.

And a fourth layer that is not optional in a real application: the server. All the browser's validation can be bypassed with the DevTools in ten seconds. It exists to help the user, never to guarantee data integrity. Nómada Tasks does not have a server yet; it will in Module 7.

  1. Business rules live in the model

This is the most important architectural decision of the lesson. Look at what Task already knows how to do since 05-02 and 05-03:

  • R1: Board.add rejects duplicated ids.
  • R2: the constructor throws ValidationError if the title is empty.
  • R3: the estimatedHours setter demands a number between 1 and 40.
  • R5: the initial status is 'pending'.
  • R9: tags are normalized and deduplicated.

It would be a mistake to rewrite all of that in the view. If you did, you would have two copies of each rule, which would go out of sync with the first modification, and Module 8 would have to test them twice.

The right thing is to try to build the task and catch the error. The view does not validate business rules: it limits itself to translating the model's error into a message on screen.

import { Task } from '../model/task.js';
import { ValidationError } from '../model/errors.js';

function tryCreate(data, board, nextId) {
  try {
    const task = new Task({ ...data, id: nextId() });   // R2, R3, R5, R9
    board.add(task);                                    // R1
    return { ok: true, task };
  } catch (error) {
    if (error instanceof ValidationError) {
      return { ok: false, field: error.field, message: error.message };
    }
    throw error;      // any other error is a genuine failure: let it rise (02-05)
  }
}

That error.field is gold: the ValidationError you designed in 05-02 already carries the name of the field that failed, so the view knows exactly where to put the message and which field to move focus to. Designing the errors that way, months before there was an interface, is what makes them fit now without any effort.

The ids are generated by the closure from 03-04, which is still the right way of not exposing a global counter:

// js/util/ids.js
export function createIdGenerator(start = 1) {
  let next = start;
  return () => next++;
}
// js/app.js
import { createIdGenerator } from './util/ids.js';
const nextId = createIdGenerator(
  Math.max(...board.tasks.map((t) => t.id)) + 1     // 7, with the canonical backlog
);

What is left are the validations that do belong to the view, because they are not domain rules but rules of this specific form: that the due date is not earlier than today, that the tags field does not bring more than five, or any check that depends on the relationship between two fields.

  1. Showing errors accessibly

An error message has to satisfy four things. If it fails one, there are people who cannot use your form:

  1. Be associated with the field, with aria-describedby, so that it is read along with it.
  2. Mark the field as invalid, with aria-invalid="true", so that it is announced as such.
  3. Announce itself when it appears, with role="alert" on the general summary.
  4. Move focus to the first field with an error, so that keyboard users do not have to hunt for it.

And a fifth requirement that is not technical: color cannot be the only signal. A red border with no text says nothing to somebody who cannot distinguish red.

// js/view/form.js (fragment)
import { $ } from './dom.js';

/** Marks a field as erroneous and shows its message. */
function markError(field, message) {
  field.setAttribute('aria-invalid', 'true');

  const errorId = `error-${field.name}`;
  let error = document.getElementById(errorId);

  if (error === null) {
    error = document.createElement('small');
    error.id = errorId;
    error.className = 'field__error';
    field.closest('.field').append(error);
  }
  error.textContent = message;

  // We add the error's id to aria-describedby WITHOUT deleting the hint's one
  const describedBy = (field.getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
  if (!describedBy.includes(errorId)) {
    field.setAttribute('aria-describedby', [...describedBy, errorId].join(' '));
  }
}

/** Clears a field's error. */
function clearError(field) {
  field.removeAttribute('aria-invalid');
  field.setCustomValidity('');                       // ← essential
  document.getElementById(`error-${field.name}`)?.remove();

  const describedBy = (field.getAttribute('aria-describedby') ?? '')
    .split(' ').filter((id) => id !== `error-${field.name}` && id !== '');
  if (describedBy.length > 0) field.setAttribute('aria-describedby', describedBy.join(' '));
  else field.removeAttribute('aria-describedby');
}
.field__error { color: var(--color-high); font-weight: 600; display: block; }
.field__error::before { content: "⚠ "; }        /* non-chromatic signal */
[aria-invalid="true"] { border: 2px solid var(--color-high); }
.errors { color: var(--color-high); font-weight: 600; }

Note the care taken with aria-describedby: it can contain several space-separated ids, and the field already had the one for its hint text. Clobbering it with setAttribute('aria-describedby', errorId) would make the hint disappear. It is a small detail with real consequences.

And the focus move, which happens only once, to the first error:

function showErrors(form, errors) {
  const summary = $('#form-errors');

  if (errors.length === 0) {
    summary.hidden = true;
    summary.textContent = '';
    return;
  }

  summary.textContent = errors.length === 1
    ? errors[0].message
    : `There are ${errors.length} fields with errors. Review them before continuing.`;
  summary.hidden = false;                     // role="alert" announces it when shown

  for (const { field, message } of errors) markError(field, message);
  errors[0].field.focus();                    // ← focus to the FIRST error
}

  1. On-the-fly validation with input and debounce

Validating only on submit is frustrating: the user fills in seven fields and finds out at the end that the second one was wrong. Validating on every key is just as annoying: "the title is too short" pops up as soon as the first letter is typed.

The balance that works:

Moment What to validate
blur / focusout The field just left (first time)
input Only the fields already marked as erroneous, to remove the error as soon as it is fixed
submit Everything

And for expensive or noisy validations —a search for duplicated titles, for instance— a debounce is applied: wait until the user has stopped typing for a while before acting. It is implemented with the closure from 03-04:

// js/util/time.js

/**
 * Returns a version of `fn` that only runs once `wait`
 * milliseconds have passed since the last call.
 */
export function debounce(fn, wait = 300) {
  let timer = null;                     // ← lives in the closure, one per created function

  return function (...args) {
    clearTimeout(timer);                // cancels the pending execution
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}
const validateTitle = debounce((field) => {
  if (field.value.trim().length < 3) markError(field, 'Minimum 3 characters.');
  else clearError(field);
}, 300);

form.elements.title.addEventListener('input', (e) => validateTitle(e.target));

A trace of what happens if Marta types "Buy white ink" in one second:

B  → schedules a validation in 300 ms
u  → cancels the previous one, schedules another
y  → cancels, schedules
…
k  → cancels, schedules
(300 ms pause)
→ it runs ONCE, with the final value

Thirteen keystrokes, a single validation. With local data the saving is symbolic; with a check against a server (Module 7) it is the difference between thirteen requests and one.

Do not confuse debounce with throttle: the first waits for the events to stop; the second runs at most once every X milliseconds, and is the right choice for scroll or resize. Both techniques are studied in detail in Optimizing JavaScript Performance.

An accessibility warning about debounce: with role="alert", every text change is announced. If you validate a field on the fly while the user types, the screen reader will interrupt constantly. That is why the on-the-fly messages go in the field's <small> (with aria-describedby, which is read when the user reaches the field) and only the submission summary uses role="alert".

  1. Submitting, resetting and returning focus

When the submission goes well, three gestures separate a careful form from a careless one:

form.reset();                             // empties the fields (respects the 'selected' ones)
form.elements.title.focus();              // returns focus to the first field
announce(`Task "${task.title}" created.`); // audible and visible confirmation
  • reset() returns every control to its initial value from the HTML, not to the empty string: that is why <option value="medium" selected> becomes selected again. It also clears the :user-invalid state.
  • Returning focus to the first field lets you create several tasks in a row without touching the mouse. If you do not, focus stays on the "Create" button and the user has to go back with Shift+Tab seven times.
  • The confirmation must be perceivable by everyone. Text appearing in a container with role="status" is announced without interrupting; a silent animation is not.

Remember also to clear the previous errors on every submission: if you do not, fields that have already been fixed stay marked.

  1. Nómada Tasks: view/form.js

The complete module, which brings the three validation layers together:

// js/view/form.js
import { $, $$ } from './dom.js';
import { debounce } from '../util/time.js';
import { Task } from '../model/task.js';
import { ValidationError } from '../model/errors.js';
import { EVENTS, emit } from './events.js';
import { TODAY } from '../util/dates.js';

/** Converts the raw form data to the model's types. */
function normalize(raw) {
  return {
    title: (raw.title ?? '').trim(),
    assignee: raw.assignee === '' ? null : raw.assignee,
    priority: raw.priority,
    estimatedHours: raw.estimatedHours === '' ? NaN : Number(raw.estimatedHours),
    dueDate: raw.dueDate,
    tags: (raw.tags ?? '')
      .split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
  };
}

/** Validations specific to THIS form (not domain rules). */
function validateForm(form, data, today) {
  const errors = [];

  for (const field of form.elements) {
    if (field.willValidate && !field.checkValidity()) {
      errors.push({ field, message: field.validationMessage });    // native layer
    }
  }

  const date = form.elements.dueDate;
  if (errors.every((e) => e.field !== date) && data.dueDate < today) {
    errors.push({ field: date, message: 'The due date cannot be earlier than today.' });
  }

  if (data.tags.length > 5) {
    errors.push({ field: form.elements.tags, message: 'Maximum 5 tags.' });
  }

  return errors;
}

export function connectForm({ form, board, nextId, onCreate,
                              today = TODAY, signal }) {

  const summary = $('#form-errors');

  function clearAll() {
    summary.hidden = true;
    summary.textContent = '';
    for (const field of form.elements) {
      if (field.name) clearError(field);
    }
  }

  // ── 1 · Submission ───────────────────────────────────────────────────────
  form.addEventListener('submit', (event) => {
    event.preventDefault();                        // essential
    clearAll();

    const data = normalize(Object.fromEntries(new FormData(form)));

    // Layer A · form validation (native + the view's rules)
    const errors = validateForm(form, data, today);
    if (errors.length > 0) { showErrors(form, errors); return; }

    // Layer B · business rules: applied by the MODEL, not by the view
    let task;
    try {
      task = new Task({ ...data, id: nextId(), status: 'pending' });   // R2, R3, R5, R9
      board.add(task);                                                 // R1
    } catch (error) {
      if (!(error instanceof ValidationError)) throw error;
      const field = form.elements[error.field] ?? form.elements.title;
      showErrors(form, [{ field, message: error.message }]);
      return;
    }

    // Success
    emit(form, EVENTS.TASK_CREATED, { id: task.id, title: task.title });
    onCreate?.(task);                              // the caller decides what to do (render)

    form.reset();
    form.elements.title.focus();
    summary.hidden = false;
    summary.textContent = `Task "${task.title}" created with identifier ${task.id}.`;
  }, { signal });

  // ── 2 · Validation when leaving a field ──────────────────────────────────
  form.addEventListener('focusout', (event) => {      // focusin/focusout DO bubble
    const field = event.target;
    if (!field.name || !field.willValidate) return;
    if (field.value === '' && !field.required) return;   // do not nag about optional fields
    if (field.checkValidity()) clearError(field);
    else markError(field, field.validationMessage);
  }, { signal });

  // ── 3 · On-the-fly correction, with debounce ─────────────────────────────
  const revalidate = debounce((field) => {
    if (field.checkValidity()) clearError(field);
  }, 300);

  form.addEventListener('input', (event) => {
    const field = event.target;
    // We only revalidate what was ALREADY marked as erroneous
    if (field.getAttribute('aria-invalid') === 'true') revalidate(field);
  }, { signal });

  // ── 4 · Clean reset ──────────────────────────────────────────────────────
  form.addEventListener('reset', () => {
    clearAll();
    setTimeout(() => form.elements.title.focus(), 0);  // after the browser's reset
  }, { signal });
}

And the final wiring in app.js, with the module's complete cycle:

// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { createIdGenerator } from './util/ids.js';
import { TODAY } from './util/dates.js';
import { BoardView } from './view/board-view.js';
import { connectForm } from './view/form.js';
import { $ } from './view/dom.js';

const board = new Board('Taller Nómada', createBacklog());
const nextId = createIdGenerator(Math.max(...board.tasks.map((t) => t.id)) + 1);

const view = new BoardView({
  container: $('.board'), summary: $('#summary'), board, today: TODAY
});
view.render();

connectForm({
  form: $('#new-task'),
  board,
  nextId,
  today: TODAY,
  onCreate: () => view.render()            // ← new state, render: the cycle from 06-06
});

Try it. Create "Buy white screen-printing ink", Lucía, high priority, 4 h, 10 October, tags "screen-printing, storeroom". The card appears in the "Not started" column, the counter goes from 3 tasks · 25 h to 4 tasks · 29 h, and the summary from 6 tasks · 5 open · 45 of 48 h to 7 tasks · 6 open · 49 of 52 h. Then try submitting with an empty title: the summary announces the error, the field is marked with aria-invalid, the message appears under the label and focus jumps to the field. And try entering 60 hours: the HTML's max="40" catches it before it reaches the model, and if you bypassed that with the DevTools, the estimatedHours setter (R3) would reject it just the same. Two layers, the same rule, a single definition.

Common Mistakes and Tips

  • Forgetting preventDefault() on the submit. The page reloads and all the state is lost. If your form "flickers and does nothing", this is the reason 90 % of the time.
  • Listening for click on the button instead of submit on the form. It breaks submitting with Enter, which many users take for granted.
  • Using <button> without a type for actions that do not submit. Inside a form, the default value is submit. Any auxiliary button needs type="button".
  • Using a placeholder instead of a <label>. It disappears when you type, it is not always announced and it cannot be clicked. The label is mandatory; the placeholder is an extra.
  • Not converting the values. Everything arrives as text: '4' > 40 gives false by coincidence and '10' > '9' gives false through string comparison. Convert with Number() in a normalization function.
  • Forgetting setCustomValidity('') when things are corrected. The field stays invalid forever and the form never submits, with no visible clue.
  • Clobbering aria-describedby. It can contain several ids; overwriting it removes the reference to the hint text. Add and remove ids while preserving the others.
  • Duplicating the business rules in the view. If Task already validates the hours range, the view must not repeat it: it must catch the ValidationError and use its field and its message. One rule, one place.
  • Marking everything red on page load. That is what plain :invalid does. Use :user-invalid, which waits for the user to interact.
  • Trusting client-side validation. It is bypassed with the DevTools in ten seconds. It is help for the user, not a guarantee; the guarantee comes from the server (Module 7).
  • Tip: make the names match the model's properties. Object.fromEntries(new FormData(form)) then produces an object almost ready for the constructor, and you save yourself an entire mapping.
  • Tip: test your form with the keyboard only. Tab, Shift+Tab, Enter, Escape. If you can fill it in, submit it, fix an error and submit it again without touching the mouse, it is well made.

Exercises

Exercise 1 · Reviewer different from the assignee

Add a <select name="reviewer"> to the form with the same people and a cross-field validation: the reviewer cannot be the same person as the assignee. It must show as an accessible error on the reviewer field, be revalidated when either of the two changes, and not prevent both from being left unassigned.

Exercise 2 · Accessible character counter

Add under the notes field a counter "120 / 200 characters" that updates as you type and warns when fewer than 20 are left. It must use input, must not interrupt screen readers on every key, and must apply a debounce to the announcement. Explain which combination of aria-live and debounce you chose and why.

Exercise 3 · Duplicates by title

Prevent the creation of two open tasks with the same title (comparing without distinguishing case or superfluous accents). Decide with reasons which layer this rule lives in, implement it there, and validate on the fly with a 400 ms debounce, showing the warning before the user presses "Create task".

Solutions

Exercise 1

<div class="field">
  <label for="reviewer">Reviewer</label>
  <select id="reviewer" name="reviewer">
    <option value="">No reviewer</option>
    <option value="Marta">Marta</option>
    <option value="Iván">Iván</option>
    <option value="Lucía">Lucía</option>
  </select>
</div>
function validateReviewer(form) {
  const reviewer = form.elements.reviewer;
  const assignee = form.elements.assignee;

  const clashes = reviewer.value !== '' && reviewer.value === assignee.value;
  reviewer.setCustomValidity(clashes ? 'The reviewer cannot be the assignee.' : '');

  if (clashes) markError(reviewer, reviewer.validationMessage);
  else clearError(reviewer);
  return !clashes;
}

// It is revalidated when EITHER of the two changes
for (const name of ['reviewer', 'assignee']) {
  form.elements[name].addEventListener('change', () => validateReviewer(form));
}

Two important decisions. The first: setCustomValidity is used, which integrates the rule into the native engine and makes the submission's checkValidity() loop detect it with no extra code. The second: we listen on both fields, because the error can appear or disappear when either of them changes; validating only the reviewer would leave a stale error if the user fixes things by changing the assignee. And the reviewer.value !== '' lets both be left empty, exactly as the exercise asked: "unassigned" and "no reviewer" do not clash with each other.

Exercise 2

<small id="notes-counter" class="hint" aria-live="polite">0 / 200 characters</small>
<textarea id="notes" name="notes" rows="2" maxlength="200"
          aria-describedby="notes-counter"></textarea>
const notes = form.elements.notes;
const counter = $('#notes-counter');

// Immediate VISUAL update, without an active aria-live
const announce = debounce((text) => { counter.textContent = text; }, 500);

notes.addEventListener('input', () => {
  const used = notes.value.length;
  const remaining = 200 - used;
  const text = `${used} / 200 characters`;

  counter.classList.toggle('hint--warning', remaining < 20);
  announce(remaining < 20 ? `${text}. ${remaining} left.` : text);
});

The chosen combination is aria-live="polite" plus a 500 ms debounce, and the reasoning is this: polite makes the reader wait until it has finished whatever it is saying before announcing the change, instead of interrupting as assertive or role="alert" would. But even being polite, updating the text on every key would queue dozens of announcements. The debounce guarantees that it is only announced when the user pauses, which is exactly the moment the information is useful to them. The warning class, by contrast, is applied immediately: it is visual information, it costs nothing and it interrupts nobody.

Exercise 3

The rule "there cannot be two open tasks with the same title" is a business rule, not a presentation one: it stays true even if the task is created from a script, from an import or from the server. It therefore lives in the model, alongside R1.

// model/board.js
#normalizeTitle(title) {
  return title.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

/** Is there already an OPEN task with this title? (R11) */
hasDuplicateTitle(title, exceptId = null) {
  const wanted = this.#normalizeTitle(title);
  return this.#tasks.some(
    (t) => t.isOpen && t.id !== exceptId && this.#normalizeTitle(t.title) === wanted
  );
}

add(task) {
  // … R1 …
  if (this.hasDuplicateTitle(task.title)) {
    throw new ValidationError(
      `An open task titled "${task.title}" already exists.`, 'title', task.title);
  }
  this.#tasks.push(task);
  return this;
}
// view/form.js — on-the-fly warning, without duplicating the rule
const warnDuplicate = debounce((field) => {
  const duplicate = board.hasDuplicateTitle(field.value);
  field.setCustomValidity(duplicate ? 'An open task with that title already exists.' : '');
  if (duplicate) markError(field, field.validationMessage);
  else clearError(field);
}, 400);

form.elements.title.addEventListener('input', (e) => warnDuplicate(e.target));

The key is that the view asks the model (board.hasDuplicateTitle(...)) instead of reimplementing the comparison. If tomorrow it is decided that done tasks also count, or that punctuation should be ignored, a single function is changed and both the on-the-fly warning and the rejection at submission behave the same way. The normalize('NFD') followed by stripping the diacritics is the standard way of comparing "Café signage" and "Cafe signage" as equal.

Conclusion

With this, Nómada Tasks is complete as a browser application. You know how to write a semantic form: every control with its <label for>, its name —made to match the model's properties, which saves an entire mapping—, its <fieldset> and <legend> for grouping, its aria-describedby for the hints and its validation attributes (required, minlength, min, max, step, pattern, type="date", type="number"). You know how to read the values at three levels, from element.value to the idiomatic Object.fromEntries(new FormData(form)), with the two limitations worth remembering: repeated names require getAll, and unchecked checkboxes simply do not appear. And you have it engraved that everything arrives as text, with '4' > 40 and '10' > '9' as reminders of why a normalization function with Number() is not optional.

You have mastered submit with its mandatory preventDefault() and its details —it fires with Enter, buttons are submit by default, event.submitter tells you which was pressed— and the two ways of validating, which do not compete but stack. The native one contributes checkValidity, reportValidity, the validity object with one flag per kind of failure, setCustomValidity (and the obligation to clear it), novalidate to keep the API without the browser's bubbles, and :user-invalid instead of :invalid so as not to dye the page red before anybody has typed. The JavaScript one contributes control of the message, of the timing and of accessibility, plus the cross-field rules. And above both stands the architectural decision that organizes the whole project: business rules live in the model. The view does not reimplement R1, R2, R3 or R9; it tries to build the Task, catches the ValidationError and uses its field and its message to know where to put the message and which field to move focus to. Designing that error with a field property, two modules before a screen existed, is what makes it fit today without a line of glue.

You know how to show errors that exist for everybody: aria-invalid="true" on the field, the message linked with aria-describedby —preserving whatever ids were already there—, a summary with role="alert" that announces itself, focus moved to the first field with an error, and a signal that does not depend on color alone. You know how to validate on the fly at the right moment —when leaving a field the first time, and on every key only to remove errors already shown— and to damp the noise with a debounce built from a closure from 03-04, telling it apart from throttle and bearing in mind that an undamped aria-live turns a screen reader into a machine gun. And you close the cycle with the three final gestures: reset(), focus to the first field and a perceivable confirmation.

This brings Module 6 to an end. You have covered the whole DOM: what the tree is and how the browser builds it; how you select with CSS selectors and how you manipulate text, attributes, dataset, classes and styles; how handlers are registered and what the Event object brings; how an event travels through the three phases and how delegation with closest() and data-id lets you serve a whole list with a single handler; how nodes are created, inserted and removed without opening security holes or memory leaks; how the cycle state → render → event → new state → render is organized with key-based reconciliation; and how user data is collected and validated. The model from Modules 1 to 5 has not changed a single line in the process: it is still pure JavaScript, without one mention of document. That boundary is what will make Module 8 possible, when you test the model without a browser.

And now open the DevTools, create three tasks for Iván, mark two as done, filter by Lucía… and press F5. Everything disappears. Back come the six tasks of data/backlog.js, the 48 h, effort 124, as if Marta had never done any work. The application looks beautiful and remembers nothing, because everything that has happened lives in the memory of a tab you have just reloaded. Worse still: even if it did remember, Marta, Iván and Lucía would each work with their own copy, with no way of seeing what the others are doing. Missing are the two halves that turn a page into a product: storing the data in the browser so that it survives a reload, and talking to a server so that the board is the same one for the whole team. That is Module 7: Browser APIs, which starts with Local and Session Storage —where the toJSON and the static fromJSON you wrote in 05-03 will finally show what they were there for.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved