Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

jQuery 4 (legacy code and migration to DOM APIs)  Module 4 – Ajax, Deferreds and forms

Forms: val(), serialize() and jQuery Validation

.val() for every form control, .serialize() against FormData and URLSearchParams field by field, and moving jQuery Validation rules to native checks.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, jQuery 4.0.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Read and set form values with .val() and .prop() for every control type
  • Compare .serialize() with FormData and URLSearchParams field by field
  • Maintain jQuery Validation rules on a release without known advisories, with escapeHtml set
  • Plan the move of each rule to the Constraint Validation API, with accessible error messages

Before you start

What you’ll learn · Before you start

What you will learn

  • Read and set form values with .val() and .prop() for every control type
  • Compare .serialize() with FormData and URLSearchParams field by field
  • Maintain jQuery Validation rules on a release without known advisories, with escapeHtml set
  • Plan the move of each rule to the Constraint Validation API, with accessible error messages

Before you start

On this page

A sign-up form’s .serialize() sends six fields, and the same form as a FormData sends seven: the file input, which .serialize() never includes. Its jQuery Validation rule pattern: /[0-9]{6}/ accepts PIN 4110012, because a RegExp rule of the plugin matches anywhere in the value, while the HTML pattern attribute must match all of it.

Form code is where small differences of this kind hide. The form pages here ran in jsdom on jQuery 4.0.0, the first two on 3.7.1 as well, with jQuery Validation 1.22.1 for the plugin’s page. You will read and set every kind of control with .val() and .prop(), compare the three serialisers field by field, keep jQuery Validation on a release without known advisories, and move its rules to the browser’s own checks with messages that screen readers announce.

.val() reads the first element; a checkbox needs :checked or .prop()

.val() gets the value of the first element of a collection, and for most controls that is all there is (.val()). Radios and checkboxes are the exception: they share a name, and .val() returns the value attribute of the first one whether it is checked or not. A select with multiple gives an array, empty when nothing is chosen since jQuery 3.0, where jQuery 1 and 2 gave null. The sign-up form logs what .val() returns for each control, then sets a few:

The sign-up form and controls.js

shop/controls.js

// What .val() reads from each kind of control of the sign-up form, and what it sets.
const field = (name) => $(`#signup [name=${name}]`);

console.log(`a text input: "${field('name').val()}"`);
console.log(`the textarea: ${JSON.stringify(field('address').val())}`);
console.log(`a select: "${field('city').val()}"`);
console.log(`a select multiple, nothing chosen: ${JSON.stringify(field('langs').val())}`);
console.log(`the radio group: "${field('plan').val()}", the checked one: "${field('plan').filter(':checked').val()}"`);
console.log(`the unticked checkbox: "${field('news').val()}", .prop('checked') ${field('news').prop('checked')}, .is(':checked') ${field('news').is(':checked')}`);

let changes = 0;
$('#signup').on('change', () => changes++);
field('plan').val(['monthly']);
field('langs').val(['hi', 'ta']);
field('news').val(['yes']);
field('terms').val('accepted');
console.log(`after .val(['monthly']), .val(['hi', 'ta']) and .val(['yes']): plan "${field('plan').filter(':checked').val()}", langs ${JSON.stringify(field('langs').val())}, news ticked ${field('news').prop('checked')}`);
console.log(`after .val('accepted') on the ticked terms box: still ticked ${field('terms').prop('checked')}, its value now "${field('terms').val()}"`);
console.log(`change events from those .val() calls: ${changes}`);
field('news').prop('checked', false).trigger('change');
console.log(`after .prop('checked', false).trigger('change'): ${changes} change event`);

shop/controls.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>The sign-up form</title>
</head>
<body>
  <form id="signup" action="/srv/register" method="post">
    <label>Name <input name="name" value="Asha K"></label>
    <label>Email <input type="email" name="email" value="asha@example.in"></label>
    <label>Address <textarea name="address">Flat 4
MG Road</textarea></label>
    <label>City <select name="city"><option>Pune</option><option selected>Chennai</option></select></label>
    <label>Languages <select name="langs" multiple><option value="hi">Hindi</option><option value="ta">Tamil</option></select></label>
    <label><input type="radio" name="plan" value="monthly"> Monthly</label>
    <label><input type="radio" name="plan" value="yearly" checked> Yearly</label>
    <label><input type="checkbox" name="news" value="yes"> Newsletter</label>
    <label><input type="checkbox" name="terms" value="ok" checked> Terms</label>
    <label>Document <input type="file" name="doc"></label>
    <label>Branch PIN <input name="branch" value="411001" disabled></label>
    <button type="submit" name="action" value="save">Save</button>
  </form>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
  <script src="controls.js"></script>
</body>
</html>
What controls.js logged, on 4.0.0 and 3.7.1 JavaScript · controls.mjs
// Prints what shop/controls.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';

show('shop/controls.html');

Output

a text input: "Asha K"
the textarea: "Flat 4\nMG Road"
a select: "Chennai"
a select multiple, nothing chosen: []
the radio group: "monthly", the checked one: "yearly"
the unticked checkbox: "yes", .prop('checked') false, .is(':checked') false
after .val(['monthly']), .val(['hi', 'ta']) and .val(['yes']): plan "monthly", langs ["hi","ta"], news ticked true
after .val('accepted') on the ticked terms box: still ticked true, its value now "accepted"
change events from those .val() calls: 0
after .prop('checked', false).trigger('change'): 1 change event
(jQuery 3.7.1 logged the same 10 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node controls.mjs

The radio group gave "monthly", the first radio, though "yearly" was checked; .filter(':checked').val() gave the right answer. The unticked newsletter box gave its value "yes" all the same, so the state has to come from .prop('checked') or .is(':checked'). Setting works the other way round: .val(['monthly']) with an array checked the radio whose value is listed, and selected the listed options, while .val('accepted') with a plain string changed the box’s value and left it ticked.

Control .val() returns Its state
text, email, textarea, select the value the value
select multiple an array, [] when nothing is chosen the array
radio group the first radio’s value .filter(':checked').val()
checkbox its value, ticked or not .prop('checked') or .is(':checked')

None of those calls fired a change event: the documentation says that setting a value never dispatches one, and the run counted 0 until .trigger('change') was called. Code that recalculates a total or shows a field on change must trigger the event after a script fills the form.

Checkpoint

Check: Reading and setting values with .val()

  1. Question 1 of 2 A radio group has monthly, then yearly, and yearly is checked. What does $("[name=plan]").val() return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "monthly", the value of the first radio

    .val() reads only the first element of the collection, as jQuery's documentation says. Narrow the collection to the checked radio first.

  2. Question 2 of 2 Code sets three fields with .val(...). How many change events fire?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: None

    Handlers that update totals or show extra fields on change must be triggered after a script sets values.

.serialize(), FormData and URLSearchParams send different bytes

All three start from the same list of fields that the HTML Standard calls the entry list: The list of names and values that the HTML Standard builds from a form when it is sent or turned into a FormData. Disabled fields, unticked boxes and every button except the one that submitted the form are left out.: it skips disabled controls, unticked checkboxes and radios, and every button except the one that submitted the form, and it gives a file input an empty File when no file is chosen (HTML Standard). .serialize() then leaves out file inputs and buttons altogether and writes a form body with %20 for spaces (.serialize()). new FormData(form) keeps the files, and new FormData(form, submitter) adds the clicked button, in Chrome 112, Firefox 111 and Safari 16.4 (browser-compat-data). Sent as multipart, a FormData writes each line break of a value as CR LF (HTML Standard). new URLSearchParams(formData) turns the FormData back into a form body with the URL Standard’s encoding, + for a space (URL Standard). The form is serialised all three ways and then posted twice:

serialize.js JavaScript · shop/serialize.js
// The sign-up form serialised three ways, then posted twice to the test server, which logs the bodies.
const form = document.getElementById('signup');
const data = new FormData(form);

console.log(`.serialize(): ${$(form).serialize()}`);
console.log(`.serializeArray() names: ${$(form).serializeArray().map((f) => f.name).join(', ')}`);
console.log(`new FormData(form) names: ${[...data.keys()].join(', ')}`);
console.log(`  doc is a ${data.get('doc').constructor.name} named "${data.get('doc').name}", the textarea ${JSON.stringify(data.get('address'))}`);
const withButton = new FormData(form, form.querySelector('[name=action]'));
console.log(`new FormData(form, submitter) adds: ${[...withButton.keys()].filter((k) => !data.has(k)).join(', ')}`);
console.log(`new URLSearchParams(new FormData(form)): ${new URLSearchParams(data)}`);

(async () => {
  await $.post('/srv/register', $(form).serialize());
  await $.ajax({ url: '/srv/register', method: 'POST', data, processData: false, contentType: false });
})();
What serialize.js logged and the server received, on 4.0.0 and 3.7.1 JavaScript · serialize.mjs
// Prints what shop/serialize.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';

show('shop/serialize.html');

Output

.serialize(): name=Asha%20K&email=asha%40example.in&address=Flat%204%0D%0AMG%20Road&city=Chennai&plan=yearly&terms=ok
.serializeArray() names: name, email, address, city, plan, terms
new FormData(form) names: name, email, address, city, plan, terms, doc
  doc is a File named "", the textarea "Flat 4\nMG Road"
new FormData(form, submitter) adds: action
new URLSearchParams(new FormData(form)): name=Asha+K&email=asha%40example.in&address=Flat+4%0AMG+Road&city=Chennai&plan=yearly&terms=ok&doc=%5Bobject+File%5D
server: POST /srv/register from XMLHttpRequest, answered 200
  Content-Type: application/x-www-form-urlencoded; charset=UTF-8
  X-Requested-With: XMLHttpRequest
  body: name=Asha+K&email=asha%40example.in&address=Flat+4%0D%0AMG+Road&city=Chennai&plan=yearly&terms=ok
server: POST /srv/register from XMLHttpRequest, answered 200
  Content-Type: multipart/form-data; boundary=BOUNDARY
  X-Requested-With: XMLHttpRequest
  body: --BOUNDARY
        Content-Disposition: form-data; name="name"
        
        Asha K
        --BOUNDARY
        Content-Disposition: form-data; name="email"
        
        asha@example.in
        --BOUNDARY
        Content-Disposition: form-data; name="address"
        
        Flat 4
        MG Road
        --BOUNDARY
        Content-Disposition: form-data; name="city"
        
        Chennai
        --BOUNDARY
        Content-Disposition: form-data; name="plan"
        
        yearly
        --BOUNDARY
        Content-Disposition: form-data; name="terms"
        
        ok
        --BOUNDARY
        Content-Disposition: form-data; name="doc"; filename=""
        Content-Type: application/octet-stream
        
        
        --BOUNDARY--
        
(jQuery 3.7.1 logged the same 45 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node serialize.mjs

The field .serialize() new FormData(form), then URLSearchParams of it
A space in Asha K Asha%20K Asha K in its part; Asha+K
The textarea’s line break %0D%0A a line feed, sent as CR LF in its part; %0A
The file input left out an empty File named ""; doc=%5Bobject+File%5D
The clicked button never with the submitter, in both
Disabled or unticked left out left out, in both

The server log adds one more step: $.post(url, $(form).serialize()) arrived as name=Asha+K and Flat+4%0D%0AMG+Road, because $.ajax() turns %20 into + in a form body, so the bytes on the wire are not the string .serialize() returned. A server decodes all of these to the same values; only code that compares raw strings, such as a signature over the body or a stored test, sees the difference. Answer three questions about a form of your own, and the table marks the serialiser that fits it:

Interactive · answer and compare Which serialiser for this form? Answer three questions about a form you are moving off jQuery; the table marks what fits it.
Does the form have a file input?
Must the server know which submit button was pressed?
Does anything compare the raw body, such as a signature or a stored test?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to send a form, compared on files, the clicked button and the exact bytes.
Serialiser File inputs The clicked button The same bytes as before
$(form).serialize() Sends: a form body as a string Poor skips them: the run had no doc field at all Poor never includes a submit button Good what the jQuery page sent: %20 for spaces, %0D%0A for line breaks
new FormData(form) Sends: multipart/form-data Good includes them, an empty File when none is chosen Good adds it when you pass the submitter: the run added action Poor another body type altogether, multipart with a boundary
URLSearchParams of a FormData Sends: a form body Poor turns a file into the text [object File] Mixed only if the FormData was made with the submitter Mixed the same fields, with + for spaces and %0A for line breaks
How the answers decide
  • “Does the form have a file input?” Yes: rules out $(form).serialize() and URLSearchParams of a FormData. Only a FormData sends the file itself; .serialize() leaves it out and URLSearchParams turns it into text.
  • “Must the server know which submit button was pressed?” Yes, there are several: rules out $(form).serialize() and adds a caveat to URLSearchParams of a FormData and new FormData(form). .serialize() never sends a button; a FormData does when it is made with the submitter, new FormData(form, submitter).
  • “Does anything compare the raw body, such as a signature or a stored test?” Yes: rules out new FormData(form) and adds a caveat to URLSearchParams of a FormData. A check of the raw body sees + where .serialize() wrote %20 and %0A where it wrote %0D%0A; keep .serialize() or update the check.

URLSearchParams around a form with a file

Wrong: fetch(url, { method: 'POST', body: new URLSearchParams(new FormData(form)) }) for a form with a file input, which sends the file as the text [object File]. Right: body: new FormData(form), which keeps the file.

Checkpoint

Check: .serialize(), FormData and URLSearchParams

  1. Question 1 of 2 The sign-up form has a file input. Which of these includes a doc field?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: new FormData(form)

    The HTML Standard adds an empty File for a file input with no file; send the FormData itself to keep it.

  2. Question 2 of 2 A test compares the body of the enquiry post with a stored string made by .serialize(). The code moves to new URLSearchParams(new FormData(form)). Why does the test fail?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Spaces become + instead of %20 and line breaks %0A instead of %0D%0A, and an empty file field is added

    A server decodes both forms the same; a check of the raw string does not. Update the check, or keep .serialize() where the exact bytes matter.

The browser checks required, type, pattern and minlength itself

The constraint validation: The browser's check of a form's fields against their rules, such as required, pattern or type="email", before the form is sent. A failed check fires an invalid event at the field and stops the sending. More in “trigger(), custom events and native listeners” of HTML checks the rules a form states in its attributes: required, type="email", pattern, minlength, min and max, and any message a script sets with setCustomValidity() (HTML Standard). Each field has a validity object with one flag per rule; checkValidity() reports whether the form is valid and fires an invalid event at each field that is not. The registration form states its rules as attributes, and its script fills the fields, as a test would:

The registration form and native.js

shop/native.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Registration</title>
</head>
<body>
  <form id="register" action="/srv/register" method="post">
    <label for="email">Email</label>
    <input id="email" type="email" name="email" required autocomplete="email" aria-describedby="email-error">
    <p id="email-error" class="field-error"></p>
    <label for="pin">PIN code</label>
    <input id="pin" name="pin" required pattern="[0-9]{6}" inputmode="numeric" autocomplete="postal-code" aria-describedby="pin-error">
    <p id="pin-error" class="field-error"></p>
    <label for="ifsc">Bank IFSC</label>
    <input id="ifsc" name="ifsc" required pattern="[A-Z]{4}0[A-Z0-9]{6}" aria-describedby="ifsc-error">
    <p id="ifsc-error" class="field-error"></p>
    <label for="password">Password</label>
    <input id="password" type="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-error">
    <p id="password-error" class="field-error"></p>
    <label for="confirm">Confirm the password</label>
    <input id="confirm" type="password" name="confirm" required autocomplete="new-password" aria-describedby="confirm-error">
    <p id="confirm-error" class="field-error"></p>
    <button>Register</button>
  </form>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
  <script src="native.js"></script>
</body>
</html>

shop/native.js

// The registration form checked by the browser itself: required, type="email", two patterns, minlength, and one
// custom rule for the second password. The script fills the fields, as a test would.
const form = document.getElementById('register');
const { email, pin, ifsc, password, confirm } = form.elements;
const KINDS = ['valueMissing', 'typeMismatch', 'patternMismatch', 'tooShort', 'customError'];
const report = (label) => {
  console.log(`${label}: form.checkValidity() is ${form.checkValidity()}`);
  for (const field of [email, pin, ifsc, password, confirm]) {
    const failed = KINDS.filter((kind) => field.validity[kind]);
    console.log(`  ${field.name}: ${failed.length ? failed.join(', ') : 'valid'}`);
  }
};
const matchPasswords = () => confirm.setCustomValidity(confirm.value === password.value ? '' : 'The passwords do not match.');

const invalid = [];
let submitted = 0;
form.addEventListener('invalid', (event) => invalid.push(event.target.name), true);
form.addEventListener('submit', (event) => {
  event.preventDefault();
  submitted++;
});

email.value = 'asha@';
pin.value = 'PIN 411001';
ifsc.value = 'sbin0001234';
password.value = 'short';
confirm.value = 'other';
matchPasswords();
report('filled in by the script');
invalid.length = 0; // checkValidity() in report() fired invalid events of its own
form.requestSubmit();
console.log(`requestSubmit(): invalid events for ${invalid.join(', ')}; submit events: ${submitted}`);

email.value = 'asha@example.in';
pin.value = '411001';
ifsc.value = 'SBIN0001234';
password.value = 'long enough';
confirm.value = 'long enough';
matchPasswords();
report('corrected');
form.requestSubmit();
console.log(`requestSubmit(): submit events: ${submitted}`);
What native.js logged JavaScript · native.mjs
// Prints what shop/native.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';

show('shop/native.html');

Output

filled in by the script: form.checkValidity() is false
  email: typeMismatch
  pin: patternMismatch
  ifsc: patternMismatch
  password: valid
  confirm: customError
requestSubmit(): invalid events for email, pin, ifsc, confirm; submit events: 0
corrected: form.checkValidity() is true
  email: valid
  pin: valid
  ifsc: valid
  password: valid
  confirm: valid
requestSubmit(): submit events: 1

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node native.mjs

The email asha@ failed typeMismatch, PIN 411001 failed its pattern, because the pattern attribute must match the whole value (the pattern attribute), and the lower-case IFSC failed the pattern [A-Z]{4}0[A-Z0-9]{6}. The second password failed the custom rule. The first password, short, passed, though the field says minlength="8": the HTML Standard counts a value as too short only when the last change to it was “a user edit”, never one made by a script. A test that sets .value never sees that flag, so check lengths in the test itself.

PIN codes and IFSC codes

A PIN code has six digits (UPU), so pattern="[0-9]{6}" with inputmode="numeric"; an IFSC has 11 characters, four letters for the bank, a 0, then six for the branch (RBI), so pattern="[A-Z]{4}0[A-Z0-9]{6}". A pattern checks the form of a code, not that it exists: look it up as the PIN code and IFSC finders do, and check it again on the server.

form.requestSubmit() behaved as pressing the button does: it fired invalid at the four failing fields and no submit event, and one submit event once every value was fixed. form.submit() skips both the constraint validation and the submit event, which the HTML Standard’s form submission algorithm states outright, so code that submits a form for the user calls requestSubmit(); Chrome 76, Firefox 75 and Safari 16 have it (browser-compat-data). To style only the fields a user has touched, the CSS selector :user-invalid waits for the user, where :invalid matches from the start; it is in Chrome 119, Firefox 88 and Safari 16.5 (browser-compat-data).

Key idea

The browser enforces the rules a field states in its attributes and reports them in validity, but only on requestSubmit() or a button press, and it checks minlength only for values a user typed.

Checkpoint

Check: The browser's constraint validation

  1. Question 1 of 2 A test sets password.value = "short" on an input with minlength="8". What does password.validity.tooShort say?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: false, because minlength is checked only for a value the user typed

    Tests that fill fields by script do not exercise minlength; check the length in the test or with a custom rule.

  2. Question 2 of 2 Which call checks the form's rules and fires the submit event, the way pressing the submit button does?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: form.requestSubmit()

    requestSubmit() is in Chrome 76, Firefox 75 and Safari 16; use it wherever code submits a form for the user.

jQuery Validation runs on jQuery 4 from 1.22, with two traps to check

jQuery Validation is the plugin most jQuery forms use for their rules, and ASP.NET Core MVC builds its client-side validation on it, with Microsoft’s jQuery Unobtrusive Validation reading the rules from data-val attributes (ASP.NET Core). Before upgrading jQuery, check which release of the plugin a site loads. Its releases fixed advisories and added jQuery 4 support in turn; walk through them:

Timeline · 7 events in order jQuery Validation releases that matter for an upgrade Walk through the releases; a page on anything before 1.20.0 carries a known advisory.
  1. 1.19.3

    A regular-expression fix security

    Fixes CVE-2021-21252, a regular expression that took very long on crafted input (ReDoS); every earlier release is affected.

  2. 1.19.4

    Another ReDoS fix security

    Fixes CVE-2021-43306, a ReDoS in the url2 method of the additional methods.

  3. 1.19.5

    A third ReDoS fix security

    Fixes CVE-2022-31147 in the same url2 method, which the fix of 1.19.4 had left open.

  4. 1.20.0

    The escapeHtml option security

    Adds escapeHtml against CVE-2025-3573, cross-site scripting through a message that repeats what the user typed. It is off by default: set escapeHtml: true.

  5. 1.21.0

    Web Components

    Validates fields inside Web Components, and accepts negative decimals without a leading zero.

  6. 1.22.0

    Runs on jQuery 4 jQuery 4

    Adds compatibility with jQuery 4.0.0, the first release of the plugin that supports it.

  7. 1.22.1

    A fix for an input named id

    Fixes a TypeError on forms that contain an input named id. Its package.json names jQuery ^1.7, ^2.0, ^3.1 and ^4.0 as peers, and this lesson runs it on jQuery 4.0.0.

The timeline has 7 events, from 1.19.3 to 1.22.1.

OSV lists four security advisory: A published notice of a security flaw in a package: what the flaw is, which releases it affects and which release fixes it. Databases such as OSV and the GitHub Advisory Database collect them for npm packages, jquery among them. More in “Why upgrade jQuery: CVEs, support and browsers” for releases before 1.20.0, three of them a regular expression that can be made to run for a very long time (ReDoS), and one, CVE: Common Vulnerabilities and Exposures: the public list that gives each known security flaw an identifier such as CVE-2020-11023, so that advisories, scanners and fixes all refer to the same problem. More in “jQuery in 2026: versions, support and where it runs”-2025-3573, cross-site scripting (XSS): A security flaw in which text from someone else, such as a comment or a URL parameter, reaches a page as markup or code and runs in visitors' browsers. Putting such text in with .text() or textContent, never as HTML, prevents it. More in “Changing content: html(), text(), append, remove” through a message (OSV). The release notes give 1.22.0 as the first with jQuery 4.0.0 compatibility (releases). The documentation sample of ASP.NET Core’s client-side validation still loads 1.19.3, so a layout copied from it carries three of those advisories. The plugin’s page runs 1.22.1 on jQuery 4.0.0 with the registration form’s rules, then the same message in two small forms:

The plugin's page: plugin.js

shop/plugin.js

// The registration form's jQuery Validation 1.22.1 rules on jQuery 4.0.0, then the same message in two forms, one
// with escapeHtml: true.
const validator = $('#register').validate({
  rules: {
    email: { required: true, email: true },
    pin: { required: true, pattern: /[0-9]{6}/ },
    password: { required: true, minlength: 8 },
    confirm: { equalTo: '#password' },
  },
  messages: { pin: { pattern: 'Enter the 6-digit PIN code.' } },
});
$('#email').val('asha@');
$('#pin').val('PIN 4110012');
$('#password').val('short');
$('#confirm').val('other');
console.log(`valid(): ${$('#register').valid()}`);
for (const error of validator.errorList) console.log(`  ${error.element.name}, ${error.method}: ${error.message}`);
console.log(`"PIN 4110012" passed the rule /[0-9]{6}/: ${validator.element('#pin')}`);
$('#pin').rules('add', { pattern: '[0-9]{6}' });
console.log(`with the rule as the string "[0-9]{6}": ${validator.element('#pin')}`);
console.log(`the message element of email: ${$('#email-error').prop('outerHTML')}`);
console.log(`the plugin set novalidate on the form: ${$('#register').attr('novalidate') !== undefined}`);

// A message that repeats what the user typed, in the form without escapeHtml and in the form with it.
const echo = { pin: { pattern: (params, element) => `"${element.value}" is not a PIN code.` } };
$('#weak').validate({ rules: { pin: { pattern: '[0-9]{6}' } }, messages: echo });
$('#safe').validate({ rules: { pin: { pattern: '[0-9]{6}' } }, messages: echo, escapeHtml: true });
for (const id of ['weak', 'safe']) {
  $(`#${id}-pin`).val('<b>4110</b>');
  $(`#${id}`).valid();
  const $message = $(`#${id}-pin-error`);
  console.log(`${id}: the message holds ${$message.find('b').length} <b> element, and reads ${JSON.stringify($message.text())}`);
}

shop/plugin.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Registration with jQuery Validation</title>
</head>
<body>
  <form id="register">
    <label for="email">Email</label> <input id="email" type="email" name="email">
    <label for="pin">PIN code</label> <input id="pin" name="pin">
    <label for="password">Password</label> <input id="password" type="password" name="password">
    <label for="confirm">Confirm the password</label> <input id="confirm" type="password" name="confirm">
    <button>Register</button>
  </form>
  <form id="weak"><label for="weak-pin">PIN code</label> <input id="weak-pin" name="pin"></form>
  <form id="safe"><label for="safe-pin">PIN code</label> <input id="safe-pin" name="pin"></form>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.22.1/dist/jquery.validate.min.js" integrity="sha256-1IuP7k/g+d98/r5+D1I2X2w+xhBVJJ+shT7D1kXXiYs=" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.22.1/dist/additional-methods.min.js" integrity="sha256-odWrYKD3o9Uu58Jq10+qgHhubxkOis989S7+nCFjE14=" crossorigin="anonymous"></script>
  <script src="plugin.js"></script>
</body>
</html>
What plugin.js logged JavaScript · plugin.mjs
// Prints what shop/plugin.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';

show('shop/plugin.html');

Output

valid(): false
  email, email: Please enter a valid email address.
  password, minlength: Please enter at least 8 characters.
  confirm, equalTo: Please enter the same value again.
"PIN 4110012" passed the rule /[0-9]{6}/: true
with the rule as the string "[0-9]{6}": false
the message element of email: <label id="email-error" class="error" for="email">Please enter a valid email address.</label>
the plugin set novalidate on the form: true
weak: the message holds 1 <b> element, and reads "\"4110\" is not a PIN code."
safe: the message holds 0 <b> element, and reads "\"<b>4110</b>\" is not a PIN code."

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node plugin.mjs

Three rules failed, but not the PIN rule: "PIN 4110012" passed the rule /[0-9]{6}/. A RegExp rule of the plugin’s pattern method is tested as it is, so it matches anywhere in the value, while a string rule is wrapped in ^(?:…)$; with the string "[0-9]{6}" the same value failed. That is the first trap. The second is in the message: a message that repeats what the user typed was inserted as HTML in the weak form, which held one <b> element, and as text in the form with escapeHtml: true. The option arrived in 1.20.0 as the fix for CVE-2025-3573 and is off by default, so the update alone does not switch it on (pull request 2462).

Keep messages as text

Set escapeHtml: true in $.validator.setDefaults() or every .validate() call, and never build a message from a field’s value with HTML around it. A check such as the page’s, which counts the elements inside the message, fails on the weak setting and passes on the fix.

Checkpoint

Check: jQuery Validation on jQuery 4

  1. Question 1 of 2 A jQuery Validation rule says pattern: /[0-9]{6}/. Is "PIN 4110012" valid?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Yes: a RegExp rule matches anywhere in the value, and the value contains six digits

    Write ^ and $ in a RegExp rule, or pass a string; the HTML pattern attribute anchors by itself.

  2. Question 2 of 2 A message function returns "${element.value}" is not a PIN code. Which setting stops typed markup from becoming part of the page?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: escapeHtml: true, which inserts messages as text

    1.20.0 added escapeHtml for CVE-2025-3573 and left it off; set it whenever a message can contain what a user typed, and keep messages as text.

Moving the rules to the browser, with messages a screen reader hears

Most rules of jQuery Validation have an attribute that does the same: required, email is type="email", minlength, maxlength, min, max, and pattern once it is anchored, since the attribute matches the whole value as OWASP advises for every pattern: “Require a match of the entire value” (Input Validation Cheat Sheet). equalTo has none, and needs setCustomValidity(); remote stays a request to the server. The plugin itself reads attributes such as required and minlength as rules, which allows a safe order: move the rules into attributes while the plugin still runs, then replace its messages with your own, then remove it. Step down one submit of the ported form:

Interactive · stage by stage One submit of the ported registration form Step down the column with the first attempt, which fails, and see where the second one leaves it.
One submit of the ported registration form, step 6 of 6. Showing Register is pressed, novalidate is set, The submit handler, setCustomValidity(), Each field's validity, Focus the first one, Send the form. In focus: Send the form.any field invalidRegister is pressedor requestSubmit()novalidate is setno browser bubblesThe submit handlerpreventDefault(), the rulessetCustomValidity()the two passwordsEach field's validitya message, aria-invalidFocus the first onenothing is sentSend the formwhen every field is valid

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Submit. Pressing Register, or calling form.requestSubmit(), starts the form submission. form.submit() would skip the rules and the submit event entirely.
  2. Our messages. The page set novalidate, so the browser shows none of its own bubbles and fires the submit event; the handler stops the real submission with preventDefault() and runs the rules itself.
  3. The custom rule. No attribute says "equal to the password field", so the handler calls setCustomValidity() on the second password: a non-empty message makes it invalid.
  4. Messages and state. For each field, the handler turns its validity flags into the page's own message, writes it into the element that aria-describedby names, and sets aria-invalid.
  5. The first attempt fails. Four fields failed: the email, the 5-digit PIN, the empty password and the second password. Focus moved to the first, email, and nothing was sent.
  6. The second attempt. With every value corrected, no field was invalid, the messages were cleared, and the form was sent: the run logged "sent".

The ported page shows its own messages: it sets novalidate, checks validity in the submit handler, writes each message into the element that the field’s aria-describedby names, and sets aria-invalid, so a screen reader announces the field as invalid with its message as the description (WAI-ARIA 1.2). It submits wrong values, then right ones:

The ported form: port.js JavaScript · shop/port.js
// The same rules on the browser's constraint validation: four attributes, setCustomValidity() for the second
// password, and the page's own messages, each in the element that the field's aria-describedby names.
const form = document.getElementById('register');
form.noValidate = true; // the page shows its own messages instead of the browser's bubbles
const fields = [...form.querySelectorAll('[aria-describedby]')];
const PATTERN = { pin: 'Enter the 6-digit PIN code.', ifsc: 'Enter the 11-character IFSC, such as SBIN0001234.' };

function messageFor(field) {
  const v = field.validity;
  if (v.valueMissing) return 'Fill in this field.';
  if (v.typeMismatch) return 'Enter an email address, such as asha@example.in.';
  if (v.patternMismatch) return PATTERN[field.name];
  if (v.tooShort) return `Use at least ${field.minLength} characters.`;
  if (v.customError) return field.validationMessage;
  return '';
}
function show(field) {
  const message = messageFor(field);
  document.getElementById(field.getAttribute('aria-describedby')).textContent = message;
  field.setAttribute('aria-invalid', String(message !== ''));
}
const matchPasswords = () => form.elements.confirm.setCustomValidity(form.elements.confirm.value === form.elements.password.value ? '' : 'The passwords do not match.');

form.addEventListener('submit', (event) => {
  event.preventDefault();
  matchPasswords();
  fields.forEach(show);
  const first = fields.find((field) => !field.validity.valid);
  if (first) {
    first.focus();
    console.log(`not sent; focus on ${document.activeElement.name}`);
    for (const field of fields) console.log(`  ${field.name}: aria-invalid ${field.getAttribute('aria-invalid')}, "${document.getElementById(field.getAttribute('aria-describedby')).textContent}"`);
    return;
  }
  console.log('sent');
});

const fill = (values) => Object.entries(values).forEach(([name, value]) => (form.elements[name].value = value));
fill({ email: 'asha@', pin: '41100', ifsc: 'SBIN0001234', password: '', confirm: 'x' });
form.requestSubmit();
fill({ email: 'asha@example.in', pin: '411001', password: 'long enough', confirm: 'long enough' });
form.requestSubmit();
What port.js logged JavaScript · port.mjs
// Prints what shop/port.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';

show('shop/port.html');

Output

not sent; focus on email
  email: aria-invalid true, "Enter an email address, such as asha@example.in."
  pin: aria-invalid true, "Enter the 6-digit PIN code."
  ifsc: aria-invalid false, ""
  password: aria-invalid true, "Fill in this field."
  confirm: aria-invalid true, "The passwords do not match."
sent

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node port.mjs

The first attempt was not sent: four fields had a message and aria-invalid="true", the IFSC had neither, and focus moved to the first invalid field, the email. The second attempt was sent. The page’s own message texts replace the browser’s, whose wording depends on the browser and its language.

jQuery Validation rule In the browser
required: true required
email: true type="email"
minlength: 8, maxlength: 64 minlength="8", maxlength="64", checked for typed values
pattern: '[0-9]{6}', or a RegExp with ^ and $ pattern="[0-9]{6}"
equalTo: '#password' setCustomValidity() in a handler
remote: url a server check, with the answer set by setCustomValidity()

The server checks again

OWASP’s cheat sheet asks for checks on the server for every field, “even when the browser checks the same fields”. A request can be sent without the page, so every allowlist pattern and length of the form belongs there too.

Checkpoint

Check: Porting rules with accessible messages

  1. Question 1 of 2 The ported form writes each message into the element that the field's aria-describedby names. What does that give a screen reader user?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The message is read with the field when it gets focus

    Move focus to the first invalid field, as the port did, so its message is heard at once.

  2. Question 2 of 2 Which jQuery Validation rule has no HTML attribute and needs setCustomValidity()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: equalTo, two fields that must match

    Rules that compare fields, or ask the server, stay in script: setCustomValidity() for the first, a server check for the second.

Practise: turn the rules into attributes

The port starts with a table like the one above, done by code for every field. Write the function that turns one field’s jQuery Validation rules into HTML attributes and lists the rules that still need a script; the sample tests include the unanchored RegExp of this lesson:

Exercise · Medium · JavaScript

Turn jQuery Validation rules into HTML attributes

A team moves its forms from jQuery Validation to the browser's constraint validation, field by field. Write toNative(rules) in rules.mjs: it takes the jQuery Validation rules of one field and returns { attributes, needsScript }, the HTML attributes that express them and the names of the rules that no attribute can express, which need setCustomValidity() or a server check:

| Rule | Becomes | | --- | --- | | required: true | the attribute required with the value '' | | email: true | type: 'email' | | minlength: n, maxlength: n | minlength and maxlength with String(n) | | min: n, max: n | min and max with String(n) | | digits: true | pattern: '[0-9]*' and inputmode: 'numeric' | | pattern: 'text' (a string) | pattern with the same text | | pattern: /…/ written ^…$, without flags | pattern with the source between ^ and $ | | pattern: /…/ without both anchors, or with flags | nothing; add 'pattern' to needsScript | | equalTo, remote, and any rule not in this table | nothing; add the rule's name to needsScript |

A rule set to false is left out. needsScript lists names in the order of the rules object. The HTML pattern attribute always matches the whole value, while a RegExp rule of jQuery Validation matches anywhere in it, which is why an unanchored one cannot become an attribute as it is. The tests pass plain rules objects.

Starter code · rules.mjs

/**
 * The HTML attributes that express a field's jQuery Validation rules, and the rules that need a script.
 * @param {Record<string, unknown>} rules
 * @returns {{ attributes: Record<string, string>, needsScript: string[] }}
 */
export function toNative(rules) {
  const attributes = {};
  for (const [name, value] of Object.entries(rules)) attributes[name] = String(value);
  return { attributes, needsScript: [] };
}
The sample tests · rules.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { toNative } from './rules.mjs';

test('required, email and the lengths become attributes', () => {
  assert.deepEqual(toNative({ required: true, email: true, minlength: 8, maxlength: 64 }), {
    attributes: { required: '', type: 'email', minlength: '8', maxlength: '64' },
    needsScript: [],
  });
});

test('min, max and digits become attributes too', () => {
  assert.deepEqual(toNative({ min: 1, max: 99, digits: true }), {
    attributes: { min: '1', max: '99', pattern: '[0-9]*', inputmode: 'numeric' },
    needsScript: [],
  });
});

test('a string pattern stays as it is, and an anchored RegExp loses its anchors', () => {
  assert.deepEqual(toNative({ pattern: '[0-9]{6}' }).attributes, { pattern: '[0-9]{6}' });
  assert.deepEqual(toNative({ pattern: /^[A-Z]{4}0[A-Z0-9]{6}$/ }).attributes, { pattern: '[A-Z]{4}0[A-Z0-9]{6}' });
});

test('an unanchored or flagged RegExp needs a script', () => {
  assert.deepEqual(toNative({ required: true, pattern: /[0-9]{6}/ }), { attributes: { required: '' }, needsScript: ['pattern'] });
  assert.deepEqual(toNative({ pattern: /^[a-z]+$/i }), { attributes: {}, needsScript: ['pattern'] });
  assert.deepEqual(toNative({ pattern: /^[0-9]{6}/ }), { attributes: {}, needsScript: ['pattern'] });
});

test('equalTo, remote and unknown rules need a script, in order', () => {
  assert.deepEqual(toNative({ required: true, equalTo: '#password', remote: '/srv/check-email', creditcard: true }), {
    attributes: { required: '' },
    needsScript: ['equalTo', 'remote', 'creditcard'],
  });
});

test('a rule set to false is left out', () => {
  assert.deepEqual(toNative({ required: false, email: false, minlength: 8 }), { attributes: { minlength: '8' }, needsScript: [] });
});
A hint

Loop over Object.entries(rules) and switch on the name. For a RegExp, rule.source is the text between the slashes and rule.flags its flags: check source.startsWith('^'), source.endsWith('$') and flags === '', then source.slice(1, -1) is the attribute.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Regex Tester Try a field's pattern against valid and invalid values with ^ and $ added, as the pattern attribute applies it.

To finish, work through this lesson’s own questions. They come singly, beginning with the idea you have practised least, and each answer you choose is explained:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 What does .val() return for a select multiple with nothing selected, on jQuery 3.0 and later?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: An empty array, []

    Code written for jQuery 1 or 2 that tests for null misses the empty array; test the length.

  2. Question 2 of 10 $("[name=terms]").val("accepted") on a ticked checkbox: what happens?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: The box stays ticked, and its value becomes "accepted"

    Use .prop("checked", true) to tick, and .val([values]) to tick the boxes whose values are listed.

  3. Question 3 of 10 A form has Save and Publish buttons with name="action". How does fetch() code send which one was pressed?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: new FormData(form, event.submitter) in the submit handler

    The HTML Standard's entry list skips every button except the submitter, which the submit event names.

  4. Question 4 of 10 Which fields of the sign-up form were missing from both .serialize() and new FormData(form)?

    Choose every answer that is right.

    Show the answer: Question 4 of 10

    Answer:

    • The disabled branch PIN
    • The unticked newsletter box

    A server that needs a value for an unticked box must default it; a disabled field that must be sent is better readonly.

  5. Question 5 of 10 Why did the native page clear its list of invalid events before calling requestSubmit()?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: Because checkValidity(), called in report(), fires invalid events too

    reportValidity() and requestSubmit() fire invalid events as checkValidity() does, so show messages from one place.

  6. Question 6 of 10 An input has pattern="[0-9]{6}". Which value passes?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: 411001

    The HTML Standard compiles the attribute so that it matches the entire value.

  7. Question 7 of 10 A page loads jquery.validate.js 1.19.3, as a documentation sample shows. Which advisories does OSV list as fixed only in later releases?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: CVE-2021-43306 and CVE-2022-31147, ReDoS, and CVE-2025-3573, cross-site scripting

    Move to 1.22.1, the release that also supports jQuery 4, and set escapeHtml: true; the 1.20.0 fix is an option that is off until you set it.

  8. Question 8 of 10 What does jQuery Validation do to the form element when .validate() runs?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: It sets novalidate, so the browser's own bubbles stay away

    When the plugin goes, a port that shows its own messages keeps novalidate and checks validity in the submit handler, as the ported form does.

  9. Question 9 of 10 A team ports a form in one release by deleting the plugin and adding attributes. Which order is safer?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: Add the attributes first, which the plugin reads as rules, then replace the plugin's messages, then remove it

    Each step is checked by the same form tests: same valid inputs accepted, same invalid ones refused, messages present and linked to their fields.

  10. Question 10 of 10 After the port, can the server stop checking the PIN code and the IFSC?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: No: client-side checks help users, and the server must still check every value it receives

    Validate on the server with the same allowlist patterns and lengths, and keep the page's checks for quick feedback.

Interview questions

Warm-up, freshers and 2–5 years: how do you read the chosen radio button and a checkbox’s state with jQuery?

For a radio group, narrow the collection to the checked radio before reading it: $('[name=plan]:checked').val(), since .val() on the whole group returns the first radio’s value whether it is checked or not. For a checkbox, read the state with .prop('checked') or .is(':checked'): .val() returns its value attribute, ticked or not. To set them, pass an array, .val(['yearly']), which checks the elements whose values are listed, or call .prop('checked', true); neither fires a change event, so trigger it when handlers depend on it.

Two harder questions from the module’s bank build on this lesson: a message that shows the markup a user typed, and the plan for moving a large form off the plugin. Write down your first step before you read the bank’s:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A bug report says that typing <b>4110</b> into a registration form's PIN field makes its error message bold. The form uses jQuery Validation 1.22.1 with a message function that repeats the value. Explain why this happens, why it is a security issue, and how you fix it and make sure it stays fixed.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    A site has 40 forms validated by jQuery Validation, several with equalTo and remote rules, some with RegExp patterns, and screen reader users have reported that errors are hard to find. Plan the move to the browser's constraint validation without a release in which any form accepts what it refused before.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • .val() reads the first element, so a radio group needs :checked and a checkbox .prop('checked'); setting with an array checks the listed values, and no setting fires change.
  • .serialize() leaves out files and buttons and writes %20 and %0D%0A; FormData keeps files and, with a submitter, the button; URLSearchParams of a FormData writes +, %0A and [object File].
  • The browser checks required, type, pattern and minlength itself, on requestSubmit() or a button press; submit() skips them, and minlength applies only to typed values.
  • jQuery Validation runs on jQuery 4 from 1.22.0; releases before 1.20.0 carry advisories, a RegExp pattern rule needs ^ and $, and messages need escapeHtml: true.
  • A port moves the rules into attributes, keeps equalTo in setCustomValidity(), shows messages through aria-describedby and aria-invalid, and leaves the server to check everything again.

Terms in this lesson

entry list
The list of names and values that the HTML Standard builds from a form when it is sent or turned into a FormData. Disabled fields, unticked boxes and every button except the one that submitted the form are left out.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.