Skip to content

Cell validator

Cell validators run when a user finishes editing a cell. Use them to enforce data rules such as required fields, numeric ranges, or pattern matching.

Overview

When you create a validator, assign it an alias so you can reference it by name in column configuration. Handsontable defines 5 aliases by default:

  • autocomplete for Handsontable.validators.AutocompleteValidator
  • date for Handsontable.validators.DateValidator
  • dropdown for Handsontable.validators.DropdownValidator
  • numeric for Handsontable.validators.NumericValidator
  • time for Handsontable.validators.TimeValidator

Aliases give you a convenient way to specify which validator runs when table validation triggers. You don’t need to reference the validator function directly, and you can swap the function behind an alias without changing your column configuration.

Invalid cell commit semantics vs. visual marking

When a validator returns false, Handsontable independently controls two separate outcomes:

  • Commit behavior — controlled by allowInvalid. When true (the default), the invalid value is written to the data source and the editor closes. When false, the editor stays open and the value is rejected until the user enters something that passes validation.
  • Visual marking — controlled by invalidCellClassName. Regardless of [allowInvalid], Handsontable applies a CSS class to every cell whose validator returned false. The default class is htInvalid; you can replace it per-column or table-wide using [invalidCellClassName].

The following snippet shows both options used together on a single column:

columns: [
{
data: 'ip',
validator: ipValidatorRegexp,
allowInvalid: true, // keep the value even when invalid
invalidCellClassName: 'my-invalid-cell' // apply a custom CSS class
}
]

These two options work independently. You can configure [allowInvalid] and [invalidCellClassName] together without one affecting the other.

Register custom cell validator

To register your own alias use Handsontable.validators.registerValidator() function. It takes two arguments:

  • validatorName - a string representing a validator function
  • validator - a validator function that will be represented by validatorName

If you’d like to register creditCardValidator under alias credit-card you have to call:

Handsontable.validators.registerValidator('credit-card', creditCardValidator);

Choose aliases wisely. If you register your validator under name that is already registered, the target function will be overwritten:

Handsontable.validators.registerValidator('date', creditCardValidator);

Now ‘date’ alias points to creditCardValidator function, not Handsontable.validators.DateValidator.

So, unless you intentionally want to overwrite an existing alias, try to choose a unique name. A good practice is prefixing your aliases with some custom name (for example your GitHub username) to minimize the possibility of name collisions. This is especially important if you want to publish your validator, because you never know aliases has been registered by the user who uses your validator.

Handsontable.validators.registerValidator('credit-card', creditCardValidator);

Someone might already registered such alias.

Handsontable.validators.registerValidator('my.credit-card', creditCardValidator);

That’s better.

Using an alias

The final touch is to use the registered aliases, so that you can easily refer to them without knowing the actual validator function.

To sum up, a well prepared validator function should look like this:

(Handsontable => {
function customValidator(query, callback) {
// ...your custom logic of the validator
callback(/* Pass `true` or `false` based on your logic */);
}
// Register an alias
Handsontable.validators.registerValidator('my.custom', customValidator);
})(Handsontable);

From now on, you can use customValidator like so:

const container = document.querySelector('#container')
const hot = new Handsontable(container, {
columns: [{
validator: 'my.custom'
}]
});

Validate decimal numbers with dot or comma separators

Use a custom validator when a column must accept decimal values with either . or , as the decimal separator. The following example validates campaign conversion rates. It accepts values such as 3.4 and 8,1, and rejects values that do not match the decimal format.

JavaScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
// Register all Handsontable's modules.
registerAllModules();
const container = document.querySelector('#example2');
const data = [
['Spring Sale 2025', 'Email', '3.4'],
['Brand Awareness Q3', 'Paid Search', '8,1'],
['Retention Push', 'In-app', '12.0'],
['Partner Webinar', 'Organic', '6,75'],
['Holiday Preview', 'Social', '9.25'],
];
function decimalValidator(value, callback) {
if (this.allowEmpty && (value === null || value === undefined || value === '')) {
callback(true);
return;
}
callback(/^\d+[.,]\d+$/.test(String(value)));
}
new Handsontable(container, {
data,
colHeaders: ['Campaign', 'Channel', 'Conversion rate'],
columns: [
{},
{},
{
validator: decimalValidator,
allowInvalid: false,
},
],
height: 'auto',
autoWrapRow: true,
autoWrapCol: true,
licenseKey: 'non-commercial-and-evaluation',
});
TypeScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
// Register all Handsontable's modules.
registerAllModules();
const container = document.querySelector('#example2')!;
const data = [
['Spring Sale 2025', 'Email', '3.4'],
['Brand Awareness Q3', 'Paid Search', '8,1'],
['Retention Push', 'In-app', '12.0'],
['Partner Webinar', 'Organic', '6,75'],
['Holiday Preview', 'Social', '9.25'],
];
type CellMeta = { allowEmpty?: boolean };
function decimalValidator(this: CellMeta, value: unknown, callback: (valid: boolean) => void) {
if (this.allowEmpty && (value === null || value === undefined || value === '')) {
callback(true);
return;
}
callback(/^\d+[.,]\d+$/.test(String(value)));
}
new Handsontable(container, {
data,
colHeaders: ['Campaign', 'Channel', 'Conversion rate'],
columns: [
{},
{},
{
validator: decimalValidator,
allowInvalid: false,
},
],
height: 'auto',
autoWrapRow: true,
autoWrapCol: true,
licenseKey: 'non-commercial-and-evaluation',
});

Use the validator method to easily validate synchronous or asynchronous changes to a cell. If you need more control, beforeValidate and afterValidate hooks are available. In the below example, email_validator_fn is an async validator that resolves after 1000 ms.

Use the allowInvalid option to define if the grid should accept input that does not validate. If you need to modify the input (e.g., censor bad words, uppercase first letter), use the plugin hook beforeChange.

By default, all invalid cells are marked by the htInvalid CSS class. If you want to use a different class name, set invalidCellClassName — this replaces htInvalid for the affected cells. Add a CSS rule in your stylesheet for the chosen class. To apply a custom class name, add the invalidCellClassName option to your Handsontable settings. For example:

For the entire table

invalidCellClassName: 'myInvalidClass'

For specific columns

columns: [
{ data: 'firstName', invalidCellClassName: 'myInvalidClass' },
{ data: 'lastName', invalidCellClassName: 'myInvalidSecondClass' },
{ data: 'address' }
]

Callback console log:

Here you will see the log
JavaScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
// Register all Handsontable's modules.
registerAllModules();
const container = document.querySelector('#example1');
const output = document.querySelector('#output');
const ipValidatorRegexp =
/^(?:\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b|null)$/;
const emailValidator = (value, callback) => {
setTimeout(() => {
if (/.+@.+/.test(value)) {
callback(true);
} else {
callback(false);
}
}, 1000);
};
new Handsontable(container, {
data: [
66 collapsed lines
{
id: 1,
name: { first: 'Joe', last: 'Fabiano' },
ip: '0.0.0.1',
email: 'Joe.Fabiano@ex.com',
},
{
id: 2,
name: { first: 'Fred', last: 'Wecler' },
ip: '0.0.0.1',
email: 'Fred.Wecler@ex.com',
},
{
id: 3,
name: { first: 'Steve', last: 'Wilson' },
ip: '0.0.0.1',
email: 'Steve.Wilson@ex.com',
},
{
id: 4,
name: { first: 'Maria', last: 'Fernandez' },
ip: '0.0.0.1',
email: 'M.Fernandez@ex.com',
},
{
id: 5,
name: { first: 'Pierre', last: 'Barbault' },
ip: '0.0.0.1',
email: 'Pierre.Barbault@ex.com',
},
{
id: 6,
name: { first: 'Nancy', last: 'Moore' },
ip: '0.0.0.1',
email: 'Nancy.Moore@ex.com',
},
{
id: 7,
name: { first: 'Barbara', last: 'MacDonald' },
ip: '0.0.0.1',
email: 'B.MacDonald@ex.com',
},
{
id: 8,
name: { first: 'Wilma', last: 'Williams' },
ip: '0.0.0.1',
email: 'Wilma.Williams@ex.com',
},
{
id: 9,
name: { first: 'Sasha', last: 'Silver' },
ip: '0.0.0.1',
email: 'Sasha.Silver@ex.com',
},
{
id: 10,
name: { first: 'Don', last: 'Pérignon' },
ip: '0.0.0.1',
email: 'Don.Pérignon@ex.com',
},
{
id: 11,
name: { first: 'Aaron', last: 'Kinley' },
ip: '0.0.0.1',
email: 'Aaron.Kinley@ex.com',
},
],
beforeChange(changes) {
for (let i = changes.length - 1; i >= 0; i--) {
const currChange = changes[i];
if (!currChange) {
continue;
}
// gently don't accept the word "foo" (remove the change at index i)
if (currChange[3] === 'foo') {
changes.splice(i, 1);
}
// if any of pasted cells contains the word "nuke", reject the whole paste
else if (currChange[3] === 'nuke') {
return false;
}
// capitalise first letter in column 1 and 2
else if (currChange[1] === 'name.first' || currChange[1] === 'name.last') {
if (currChange[3] !== null) {
changes[i][3] = currChange[3].charAt(0).toUpperCase() + currChange[3].slice(1);
}
}
}
return true;
},
afterChange(changes, source) {
if (source !== 'loadData') {
output.innerText = JSON.stringify(changes);
}
},
colHeaders: ['ID', 'First name', 'Last name', 'IP', 'E-mail'],
height: 'auto',
licenseKey: 'non-commercial-and-evaluation',
columns: [
{ data: 'id', type: 'numeric' },
{ data: 'name.first' },
{ data: 'name.last' },
{ data: 'ip', validator: ipValidatorRegexp, allowInvalid: true },
{ data: 'email', validator: emailValidator },
],
autoWrapRow: true,
autoWrapCol: true,
});
TypeScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
// Register all Handsontable's modules.
registerAllModules();
const container = document.querySelector('#example1')!;
const output = document.querySelector('#output') as HTMLElement;
const ipValidatorRegexp =
/^(?:\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b|null)$/;
const emailValidator = (value: string, callback: (value: boolean) => void) => {
setTimeout(() => {
if (/.+@.+/.test(value)) {
callback(true);
} else {
callback(false);
}
}, 1000);
};
new Handsontable(container, {
data: [
66 collapsed lines
{
id: 1,
name: { first: 'Joe', last: 'Fabiano' },
ip: '0.0.0.1',
email: 'Joe.Fabiano@ex.com',
},
{
id: 2,
name: { first: 'Fred', last: 'Wecler' },
ip: '0.0.0.1',
email: 'Fred.Wecler@ex.com',
},
{
id: 3,
name: { first: 'Steve', last: 'Wilson' },
ip: '0.0.0.1',
email: 'Steve.Wilson@ex.com',
},
{
id: 4,
name: { first: 'Maria', last: 'Fernandez' },
ip: '0.0.0.1',
email: 'M.Fernandez@ex.com',
},
{
id: 5,
name: { first: 'Pierre', last: 'Barbault' },
ip: '0.0.0.1',
email: 'Pierre.Barbault@ex.com',
},
{
id: 6,
name: { first: 'Nancy', last: 'Moore' },
ip: '0.0.0.1',
email: 'Nancy.Moore@ex.com',
},
{
id: 7,
name: { first: 'Barbara', last: 'MacDonald' },
ip: '0.0.0.1',
email: 'B.MacDonald@ex.com',
},
{
id: 8,
name: { first: 'Wilma', last: 'Williams' },
ip: '0.0.0.1',
email: 'Wilma.Williams@ex.com',
},
{
id: 9,
name: { first: 'Sasha', last: 'Silver' },
ip: '0.0.0.1',
email: 'Sasha.Silver@ex.com',
},
{
id: 10,
name: { first: 'Don', last: 'Pérignon' },
ip: '0.0.0.1',
email: 'Don.Pérignon@ex.com',
},
{
id: 11,
name: { first: 'Aaron', last: 'Kinley' },
ip: '0.0.0.1',
email: 'Aaron.Kinley@ex.com',
},
],
beforeChange(changes) {
for (let i = changes.length - 1; i >= 0; i--) {
const currChange = changes[i];
if (!currChange) {
continue;
}
// gently don't accept the word "foo" (remove the change at index i)
if (currChange[3] === 'foo') {
changes.splice(i, 1);
}
// if any of pasted cells contains the word "nuke", reject the whole paste
else if (currChange[3] === 'nuke') {
return false;
}
// capitalise first letter in column 1 and 2
else if (currChange[1] === 'name.first' || currChange[1] === 'name.last') {
if (currChange[3] !== null && typeof currChange[3] === 'string') {
changes[i]![3] = currChange[3].charAt(0).toUpperCase() + currChange[3].slice(1);
}
}
}
return true;
},
afterChange(changes, source) {
if (source !== 'loadData') {
output.innerText = JSON.stringify(changes);
}
},
colHeaders: ['ID', 'First name', 'Last name', 'IP', 'E-mail'],
height: 'auto',
licenseKey: 'non-commercial-and-evaluation',
columns: [
{ data: 'id', type: 'numeric' },
{ data: 'name.first' },
{ data: 'name.last' },
{ data: 'ip', validator: ipValidatorRegexp, allowInvalid: true },
{ data: 'email', validator: emailValidator },
],
autoWrapRow: true,
autoWrapCol: true,
});
HTML
<output class="console" id="output">Here you will see the log</output>
<div id="example1"></div>

Edit the above grid to see the changes argument from the callback.

Mind that changes in table are applied after running all validators (both synchronous and and asynchronous) from every changed cell.

FAQ: Does the invalid CSS class still apply when allowInvalid is true?

Yes. When allowInvalid is and a validator returns false, the value is written to the data source (the editor closes normally), but Handsontable still applies the invalid CSS class to the cell. By default that class is htInvalid; you can change it per-column or globally with the invalidCellClassName option. The two concerns — whether to accept the value and how to mark the cell — are fully independent. See also the allowInvalid option for details.

Result

You now have a cell validator that enforces data rules when a user finishes editing. Register it under an alias to reference it by name across your column configuration. Use allowInvalid set to false to keep the editor open until the user enters a valid value, or allowInvalid set to true to accept the value while still visually flagging the cell. Use invalidCellClassName to customise the CSS class applied to cells that fail validation — the default is htInvalid.

APIs

Configuration options

Core methods

Hooks