Skip to content

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.

JavaScript
import { HotTable, HotColumn } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import { registerCellType, DateCellType } from 'handsontable/cellTypes';
import moment from 'moment';
registerAllModules();
// 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);
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 */
const ExampleComponent = () => {
return (<HotTable data={data} colHeaders={['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost']} autoRowSize={true} rowHeaders={true} height="auto" width="100%" autoWrapRow={true} headerClassName="htLeft" beforeChange={correctDatesBeforeChange} licenseKey="non-commercial-and-evaluation">
<HotColumn data="itemName" type="text" width={130}/>
<HotColumn data="category" type="text" width={120}/>
<HotColumn data="leadEngineer" type="text" width={150}/>
<HotColumn data="restockDate" type="moment-date" width={150} renderFormat="MMM D, YYYY" inputFormat="MM/DD/YYYY" correctFormat={true}/>
<HotColumn data="cost" type="numeric" width={120} className="htRight" locale="en-US" numericFormat={{ style: 'currency', currency: 'USD', minimumFractionDigits: 2 }}/>
</HotTable>);
};
export default ExampleComponent;
TypeScript
import { HotTable, HotColumn } from '@handsontable/react-wrapper';
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';
registerAllModules();
// 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);
/* start:skip-in-preview */
interface InventoryItem {
id: number;
itemName: string;
itemNo: string;
leadEngineer: string;
cost: number;
inStock: boolean;
category: string;
itemQuality: number;
origin: string;
quantity: number;
valueStock: number;
repairable: boolean;
supplierName: string;
restockDate: string;
operationalStatus: string;
}
const data: InventoryItem[] = [
{
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 */
const ExampleComponent = () => {
return (
<HotTable
data={data}
colHeaders={['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost']}
autoRowSize={true}
rowHeaders={true}
height="auto"
width="100%"
autoWrapRow={true}
headerClassName="htLeft"
beforeChange={correctDatesBeforeChange}
licenseKey="non-commercial-and-evaluation"
>
<HotColumn data="itemName" type="text" width={130} />
<HotColumn data="category" type="text" width={120} />
<HotColumn data="leadEngineer" type="text" width={150} />
<HotColumn
data="restockDate"
type="moment-date"
width={150}
renderFormat="MMM D, YYYY"
inputFormat="MM/DD/YYYY"
correctFormat={true}
/>
<HotColumn
data="cost"
type="numeric"
width={120}
className="htRight"
locale="en-US"
numericFormat={{ style: 'currency', currency: 'USD', minimumFractionDigits: 2 }}
/>
</HotTable>
);
};
export default ExampleComponent;

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-in date cell 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/2025 into ISO
  • Rejects values that are not dates at all

Prerequisites

Terminal window
npm install moment
  1. Import 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:

    • DateCellType is the built-in date cell type: a native date input, an ISO validator, and a source-data check
    • moment handles date parsing and formatting
    • registerCellType registers the composed cell type for use in column config
  2. 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 inputFormat first, 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 Date rather 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.

  3. Format the Display Value

    valueFormatter converts 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 valueFormatter and not a renderer?

    Handsontable applies valueFormatter before the renderer and hands the renderer the formatted result. A custom renderer that ran moment() on its value would 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.

  4. Correct Loosely Written Values in beforeChange

    The 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/YYYY string arrives. Correct it in beforeChange:

    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 beforeChange handler.

    Why beforeChange and not the validator?

    beforeChange runs 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, with setDataAtCell — also works, but the built-in editor receives the raw value first and logs DateEditor: 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.

    getCellMetaTransient reads the resolved cell configuration without permanently materializing meta for the cell, which is what you want for a per-change read inside a hook.

  5. 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 column
    • renderFormat: 'MMM D, YYYY' - the Moment.js format the cell displays
    • inputFormat: 'MM/DD/YYYY' - the format tried first when correcting a pasted value
    • correctFormat: true - opts the column into that correction

    Spreading DateCellType copies its CELL_TYPE value, '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

  1. Initial Render: the cell holds an ISO date; valueFormatter converts it to renderFormat for display
  2. User clicks cell: the built-in editor opens the browser’s native date picker, populated from the raw ISO source value
  3. Date selection: the native input always yields an ISO value, so it is stored as-is
  4. Paste: beforeChange rewrites a pasted non-ISO value to ISO when correctFormat is set
  5. 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.