Moment.js-based date
This tutorial shows you how to layer Moment.js on top of Handsontable’s built-in date cell type, so a column keeps its own display format and still accepts loosely written dates.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { registerCellType, DateCellType } from 'handsontable/cellTypes';import moment from 'moment';// Register all Handsontable's modules.registerAllModules();/* start:skip-in-preview */const data = [ { id: 640329, itemName: 'Lunar Core', itemNo: 'XJ-12', leadEngineer: 'Ellen Ripley', cost: 350000, inStock: true, category: 'Lander', itemQuality: 87, origin: '🇺🇸 USA', quantity: 2, valueStock: 700000, repairable: false, supplierName: 'TechNova', restockDate: '2025-08-01', operationalStatus: 'Awaiting Parts', }, { id: 863104, itemName: 'Zero Thrusters', itemNo: 'QL-54', leadEngineer: 'Sam Bell', cost: 450000, inStock: false, category: 'Propulsion', itemQuality: 0, origin: '🇩🇪 Germany', quantity: 0, valueStock: 0, repairable: true, supplierName: 'PropelMax', restockDate: '2025-09-15', operationalStatus: 'In Maintenance', }, { id: 395603, itemName: 'EVA Suits', itemNo: 'PM-67', leadEngineer: 'Alex Rogan', cost: 150000, inStock: true, category: 'Equipment', itemQuality: 79, origin: '🇮🇹 Italy', quantity: 50, valueStock: 7500000, repairable: true, supplierName: 'SuitCraft', restockDate: '2025-10-05', operationalStatus: 'Ready for Testing', }, { id: 679083, itemName: 'Solar Panels', itemNo: 'BW-09', leadEngineer: 'Dave Bowman', cost: 75000, inStock: true, category: 'Energy', itemQuality: 95, origin: '🇺🇸 USA', quantity: 10, valueStock: 750000, repairable: false, supplierName: 'SolarStream', restockDate: '2025-11-10', operationalStatus: 'Operational', }, { id: 912663, itemName: 'Comm Array', itemNo: 'ZR-56', leadEngineer: 'Louise Banks', cost: 125000, inStock: false, category: 'Communication', itemQuality: 0, origin: '🇯🇵 Japan', quantity: 0, valueStock: 0, repairable: true, supplierName: 'CommTech', restockDate: '2025-12-20', operationalStatus: 'Decommissioned', }, { id: 315806, itemName: 'Habitat Dome', itemNo: 'UJ-23', leadEngineer: 'Dr. Ryan Stone', cost: 1000000, inStock: true, category: 'Shelter', itemQuality: 93, origin: '🇨🇦 Canada', quantity: 3, valueStock: 3000000, repairable: false, supplierName: 'DomeInnovate', restockDate: '2026-01-25', operationalStatus: 'Operational', },];/* end:skip-in-preview */// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector('#example1');// The built-in `date` cell type stores every value in the ISO 8601 format.const ISO_FORMAT = 'YYYY-MM-DD';// Converts a loosely written date into ISO.const toISODate = (value, inputFormat) => { // The column's own format wins, parsed strictly so a near-miss does not silently shift. const fromInputFormat = moment(value, inputFormat, true); if (fromInputFormat.isValid()) { return fromInputFormat.format(ISO_FORMAT); } // Fall back to the browser's parsing for values that format cannot describe, such as // "March 14, 2025". Handing Moment a Date avoids its string-parsing deprecation warning. const nativeDate = new Date(value); return Number.isNaN(nativeDate.getTime()) ? value : moment(nativeDate).format(ISO_FORMAT);};const cellDateTypeDefinition = { // Inherit the built-in date editor (a native date input), its ISO validator, and the source-data // check that warns when the underlying data is not ISO. ...DateCellType, // Display the ISO source value in the column's own Moment format. `valueFormatter` runs before the // renderer, so the inherited renderer receives the formatted string and no custom renderer is needed. valueFormatter: (value, cellProperties) => { if (typeof value !== 'string' || value === '') { return value; } const date = moment(value, ISO_FORMAT, true); return date.isValid() ? date.format(cellProperties.renderFormat ?? ISO_FORMAT) : value; },};// Rewrites a non-ISO value into ISO before it reaches the cell. This runs ahead of both the editor// and the validator, which is what keeps the built-in ISO-only editor from warning about the raw// value. It also covers pasted and programmatically written values, which never touch the editor.function correctDatesBeforeChange(changes) { changes.forEach((change) => { if (!change) { return; } const [visualRow, prop, , newValue] = change; const cellMeta = this.getCellMetaTransient(visualRow, this.propToCol(prop)); if (cellMeta.type !== 'moment-date' || cellMeta.correctFormat !== true || typeof newValue !== 'string' || newValue === '') { return; } if (!moment(newValue, ISO_FORMAT, true).isValid()) { change[3] = toISODate(newValue, cellMeta.inputFormat ?? ISO_FORMAT); } });}registerCellType('moment-date', cellDateTypeDefinition);// Define configuration options for the Handsontableconst hotOptions = { data, colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost'], autoRowSize: true, rowHeaders: true, height: 'auto', width: '100%', autoWrapRow: true, headerClassName: 'htLeft', columns: [ { data: 'itemName', type: 'text', width: 130 }, { data: 'category', type: 'text', width: 120 }, { data: 'leadEngineer', type: 'text', width: 150 }, { data: 'restockDate', type: 'moment-date', width: 150, // Display format, applied by `valueFormatter`. The stored value stays ISO. renderFormat: 'MMM D, YYYY', // Format tried first when correcting a pasted value. inputFormat: 'MM/DD/YYYY', correctFormat: true, }, { data: 'cost', type: 'numeric', width: 120, className: 'htRight', locale: 'en-US', numericFormat: { style: 'currency', currency: 'USD', minimumFractionDigits: 2, }, }, ], licenseKey: 'non-commercial-and-evaluation', beforeChange: correctDatesBeforeChange,};// Initialize the Handsontable instance with the specified configuration options// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, hotOptions);import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { registerCellType, DateCellType } from 'handsontable/cellTypes';import { CellChange, CellProperties } from 'handsontable/settings';import { HotInstance } from 'handsontable';import moment from 'moment';
// Register all Handsontable's modules.registerAllModules();
/* start:skip-in-preview */const data = [ { id: 640329, itemName: 'Lunar Core', itemNo: 'XJ-12', leadEngineer: 'Ellen Ripley', cost: 350000, inStock: true, category: 'Lander', itemQuality: 87, origin: '🇺🇸 USA', quantity: 2, valueStock: 700000, repairable: false, supplierName: 'TechNova', restockDate: '2025-08-01', operationalStatus: 'Awaiting Parts', }, { id: 863104, itemName: 'Zero Thrusters', itemNo: 'QL-54', leadEngineer: 'Sam Bell', cost: 450000, inStock: false, category: 'Propulsion', itemQuality: 0, origin: '🇩🇪 Germany', quantity: 0, valueStock: 0, repairable: true, supplierName: 'PropelMax', restockDate: '2025-09-15', operationalStatus: 'In Maintenance', }, { id: 395603, itemName: 'EVA Suits', itemNo: 'PM-67', leadEngineer: 'Alex Rogan', cost: 150000, inStock: true, category: 'Equipment', itemQuality: 79, origin: '🇮🇹 Italy', quantity: 50, valueStock: 7500000, repairable: true, supplierName: 'SuitCraft', restockDate: '2025-10-05', operationalStatus: 'Ready for Testing', }, { id: 679083, itemName: 'Solar Panels', itemNo: 'BW-09', leadEngineer: 'Dave Bowman', cost: 75000, inStock: true, category: 'Energy', itemQuality: 95, origin: '🇺🇸 USA', quantity: 10, valueStock: 750000, repairable: false, supplierName: 'SolarStream', restockDate: '2025-11-10', operationalStatus: 'Operational', }, { id: 912663, itemName: 'Comm Array', itemNo: 'ZR-56', leadEngineer: 'Louise Banks', cost: 125000, inStock: false, category: 'Communication', itemQuality: 0, origin: '🇯🇵 Japan', quantity: 0, valueStock: 0, repairable: true, supplierName: 'CommTech', restockDate: '2025-12-20', operationalStatus: 'Decommissioned', }, { id: 315806, itemName: 'Habitat Dome', itemNo: 'UJ-23', leadEngineer: 'Dr. Ryan Stone', cost: 1000000, inStock: true, category: 'Shelter', itemQuality: 93, origin: '🇨🇦 Canada', quantity: 3, valueStock: 3000000, repairable: false, supplierName: 'DomeInnovate', restockDate: '2026-01-25', operationalStatus: 'Operational', },];/* end:skip-in-preview */// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector('#example1')!;
// The built-in `date` cell type stores every value in the ISO 8601 format.const ISO_FORMAT = 'YYYY-MM-DD';
// Converts a loosely written date into ISO.const toISODate = (value: string, inputFormat: string): string => { // The column's own format wins, parsed strictly so a near-miss does not silently shift. const fromInputFormat = moment(value, inputFormat, true);
if (fromInputFormat.isValid()) { return fromInputFormat.format(ISO_FORMAT); }
// Fall back to the browser's parsing for values that format cannot describe, such as // "March 14, 2025". Handing Moment a Date avoids its string-parsing deprecation warning. const nativeDate = new Date(value);
return Number.isNaN(nativeDate.getTime()) ? value : moment(nativeDate).format(ISO_FORMAT);};
// The custom cell properties this cell type reads, on top of the built-in ones.type MomentDateCellProperties = CellProperties & { renderFormat?: string; inputFormat?: string; correctFormat?: boolean;};
const cellDateTypeDefinition = { // Inherit the built-in date editor (a native date input), its ISO validator, and the source-data // check that warns when the underlying data is not ISO. ...DateCellType,
// Display the ISO source value in the column's own Moment format. `valueFormatter` runs before the // renderer, so the inherited renderer receives the formatted string and no custom renderer is needed. valueFormatter: (value: unknown, cellProperties: MomentDateCellProperties) => { if (typeof value !== 'string' || value === '') { return value; }
const date = moment(value, ISO_FORMAT, true);
return date.isValid() ? date.format(cellProperties.renderFormat ?? ISO_FORMAT) : value; },};
// Rewrites a non-ISO value into ISO before it reaches the cell. This runs ahead of both the editor// and the validator, which is what keeps the built-in ISO-only editor from warning about the raw// value. It also covers pasted and programmatically written values, which never touch the editor.function correctDatesBeforeChange(this: HotInstance, changes: (CellChange | null)[]): void { changes.forEach((change) => { if (!change) { return; }
const [visualRow, prop, , newValue] = change; const cellMeta = this.getCellMetaTransient( visualRow, this.propToCol(prop as string) as number ) as MomentDateCellProperties;
if ( cellMeta.type !== 'moment-date' || cellMeta.correctFormat !== true || typeof newValue !== 'string' || newValue === '' ) { return; }
if (!moment(newValue, ISO_FORMAT, true).isValid()) { change[3] = toISODate(newValue, cellMeta.inputFormat ?? ISO_FORMAT); } });}
registerCellType('moment-date', cellDateTypeDefinition);
// Define configuration options for the Handsontableconst hotOptions: Handsontable.GridSettings = { data, colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost'], autoRowSize: true, rowHeaders: true, height: 'auto', width: '100%', autoWrapRow: true, headerClassName: 'htLeft', columns: [ { data: 'itemName', type: 'text', width: 130 }, { data: 'category', type: 'text', width: 120 }, { data: 'leadEngineer', type: 'text', width: 150 }, { data: 'restockDate', type: 'moment-date', width: 150, // Display format, applied by `valueFormatter`. The stored value stays ISO. renderFormat: 'MMM D, YYYY', // Format tried first when correcting a pasted value. inputFormat: 'MM/DD/YYYY', correctFormat: true, }, { data: 'cost', type: 'numeric', width: 120, className: 'htRight', locale: 'en-US', numericFormat: { style: 'currency', currency: 'USD', minimumFractionDigits: 2, }, }, ], licenseKey: 'non-commercial-and-evaluation', beforeChange: correctDatesBeforeChange,};
// Initialize the Handsontable instance with the specified configuration options// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, hotOptions);Overview
This guide shows how to build a custom date cell type on top of the built-in date cell type using the Moment.js library. The built-in type supplies the editor — a native date input — along with ISO validation. Moment.js adds two things it does not do: a per-column display format, and correction of dates written in another format.
Difficulty: Beginner
Time: ~15 minutes
Libraries: moment
What You’ll Build
A cell that:
- Stores dates in the ISO 8601 format (
YYYY-MM-DD), as the built-indatecell type requires - Displays them in a per-column Moment.js format, such as
MMM D, YYYY - Opens the browser’s native date picker when edited
- Rewrites pasted dates such as
03/14/2025into ISO - Rejects values that are not dates at all
Prerequisites
npm install momentImport Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { registerCellType, DateCellType } from 'handsontable/cellTypes';import moment from 'moment';registerAllModules();Why this matters:
DateCellTypeis the built-indatecell type: a native date input, an ISO validator, and a source-data checkmomenthandles date parsing and formattingregisterCellTyperegisters the composed cell type for use in column config
Create the ISO Conversion Helper
The built-in cell type stores every value as an ISO date string. This helper turns a loosely written date into that format:
const ISO_FORMAT = 'YYYY-MM-DD';const toISODate = (value: string, inputFormat: string): string => {const fromInputFormat = moment(value, inputFormat, true);if (fromInputFormat.isValid()) {return fromInputFormat.format(ISO_FORMAT);}const nativeDate = new Date(value);return Number.isNaN(nativeDate.getTime()) ? value : moment(nativeDate).format(ISO_FORMAT);};What’s happening:
- Parses strictly against the column’s
inputFormatfirst, so a near-miss does not silently shift the date - Falls back to the browser’s own parsing for values that format cannot describe, such as
March 14, 2025 - Returns the value untouched when neither reading produces a date, so the validator can reject it
Hand the fallback a
Daterather than the raw string.moment('03/14/2025')logs a deprecation warning for any input that is not RFC2822 or ISO;moment(new Date('03/14/2025'))does not.- Parses strictly against the column’s
Format the Display Value
valueFormatterconverts the stored ISO value into the format the column displays:valueFormatter: (value, cellProperties) => {if (typeof value !== 'string' || value === '') {return value;}const date = moment(value, ISO_FORMAT, true);return date.isValid() ? date.format(cellProperties.renderFormat ?? ISO_FORMAT) : value;},Why
valueFormatterand not a renderer?Handsontable applies
valueFormatterbefore the renderer and hands the renderer the formatted result. A custom renderer that ranmoment()on itsvaluewould receive the already-formatted string and parse the wrong thing. Formatting here lets the cell type keep the inherited renderer untouched.The editor is unaffected: it reads the raw source data, so the native date input always receives an ISO value no matter how the cell displays it.
Correct Loosely Written Values in
beforeChangeThe native date input can only produce ISO values, so anything typed in the editor is already correct. Pasted values and programmatic writes never reach the editor, and that is where a
MM/DD/YYYYstring arrives. Correct it inbeforeChange:function correctDatesBeforeChange(changes) {changes.forEach((change) => {if (!change) {return;}const [visualRow, prop, , newValue] = change;const cellMeta = this.getCellMetaTransient(visualRow, this.propToCol(prop));if (cellMeta.type !== 'moment-date' ||cellMeta.correctFormat !== true ||typeof newValue !== 'string' ||newValue === '') {return;}if (!moment(newValue, ISO_FORMAT, true).isValid()) {change[3] = toISODate(newValue, cellMeta.inputFormat ?? ISO_FORMAT);}});}Pass it as the grid’s
beforeChangehandler.Why
beforeChangeand not the validator?beforeChangeruns before both the editor and the validator, so the corrected ISO value is the only value the rest of the grid ever sees. Correcting later — inside a validator, withsetDataAtCell— also works, but the built-in editor receives the raw value first and logsDateEditor: value must be in ISO date format. Rewriting the change up front avoids that entirely, and it leaves the inherited ISO validator untouched, so a value Moment.js cannot read is still flagged.getCellMetaTransientreads the resolved cell configuration without permanently materializing meta for the cell, which is what you want for a per-change read inside a hook.Compose and Register the Cell Type
Spread the built-in cell type, then override the two pieces Moment.js owns:
const cellDateTypeDefinition = {...DateCellType,valueFormatter: /* from Step 3 */,};registerCellType('moment-date', cellDateTypeDefinition);const hotOptions: Handsontable.GridSettings = {data,colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost'],autoRowSize: true,rowHeaders: true,height: 'auto',width: '100%',autoWrapRow: true,headerClassName: 'htLeft',columns: [{ data: 'itemName', type: 'text', width: 130 },{ data: 'category', type: 'text', width: 120 },{ data: 'leadEngineer', type: 'text', width: 150 },{data: 'restockDate',type: 'moment-date',width: 150,renderFormat: 'MMM D, YYYY',inputFormat: 'MM/DD/YYYY',correctFormat: true,},{data: 'cost',type: 'numeric',width: 120,className: 'htRight',locale: 'en-US',numericFormat: {style: 'currency',currency: 'USD',minimumFractionDigits: 2,},},],licenseKey: 'non-commercial-and-evaluation',beforeChange: correctDatesBeforeChange,};const hot = new Handsontable(container, hotOptions);Key configuration:
type: 'moment-date'- uses the composed cell type on the Restock Date columnrenderFormat: 'MMM D, YYYY'- the Moment.js format the cell displaysinputFormat: 'MM/DD/YYYY'- the format tried first when correcting a pasted valuecorrectFormat: true- opts the column into that correction
Spreading
DateCellTypecopies itsCELL_TYPEvalue,'date', into the object. Handsontable ignores that key when expanding a cell type, so registering the result under a different name is safe.
How It Works - Complete Flow
- Initial Render: the cell holds an ISO date;
valueFormatterconverts it torenderFormatfor display - User clicks cell: the built-in editor opens the browser’s native date picker, populated from the raw ISO source value
- Date selection: the native input always yields an ISO value, so it is stored as-is
- Paste:
beforeChangerewrites a pasted non-ISO value to ISO whencorrectFormatis set - Save: values that are not dates fail the built-in ISO validator and are flagged
What you learned
You built a custom cell type by composing the built-in date cell type with Moment.js. You used valueFormatter for per-column display formatting, corrected loosely written values in beforeChange so the ISO-only editor never sees them, and registered the result with registerCellType.
Next steps
- Pikaday - A standalone Pikaday date picker recipe that also serves as a migration path from the built-in date cell type.
- Moment.js time - The same Moment.js pattern applied to time values.
- Flatpickr - An alternative date picker using the Flatpickr library with dark theme support.