Persist and restore column widths and order
In this tutorial, you will save column widths and column order to localStorage as the user resizes or reorders columns. You will learn how to restore that layout on grid initialization so user preferences survive a page refresh.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const STORAGE_KEY = 'ht-column-layout-v1';
/* start:skip-in-preview */const data = [ { sku: 'SKU-001', name: 'Wireless Keyboard', category: 'Electronics', price: 49.99, stock: 142, status: 'Active' }, { sku: 'SKU-002', name: 'USB-C Hub', category: 'Electronics', price: 34.99, stock: 87, status: 'Active' }, { sku: 'SKU-003', name: 'Ergonomic Chair', category: 'Furniture', price: 399.00, stock: 23, status: 'Active' }, { sku: 'SKU-004', name: 'Monitor Stand', category: 'Furniture', price: 79.99, stock: 55, status: 'Active' }, { sku: 'SKU-005', name: 'Noise-Cancelling Headphones', category: 'Electronics', price: 199.99, stock: 0, status: 'Out of Stock' }, { sku: 'SKU-006', name: 'Mechanical Keyboard', category: 'Electronics', price: 129.99, stock: 34, status: 'Active' }, { sku: 'SKU-007', name: 'Standing Desk', category: 'Furniture', price: 549.00, stock: 12, status: 'Active' }, { sku: 'SKU-008', name: 'Webcam HD', category: 'Electronics', price: 89.99, stock: 61, status: 'Active' }, { sku: 'SKU-009', name: 'Cable Organizer', category: 'Accessories', price: 14.99, stock: 203, status: 'Active' }, { sku: 'SKU-010', name: 'Laptop Stand', category: 'Accessories', price: 29.99, stock: 0, status: 'Discontinued' }, { sku: 'SKU-011', name: 'Blue Light Glasses', category: 'Accessories', price: 24.99, stock: 98, status: 'Active' }, { sku: 'SKU-012', name: 'Desk Lamp', category: 'Furniture', price: 44.99, stock: 77, status: 'Active' },];/* end:skip-in-preview */
// Default column widths and order used when no saved layout exists.const DEFAULT_COL_WIDTHS = [90, 200, 120, 90, 70, 110];const DEFAULT_COL_ORDER = [0, 1, 2, 3, 4, 5];
/** * Reads the saved layout from localStorage. * Returns null when the key is absent, unparseable, or from an older schema. */function loadLayout() { try { const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; const parsed = JSON.parse(raw);
// Guard against stale data that lacks expected keys. if (!Array.isArray(parsed.widths) || !Array.isArray(parsed.order)) return null;
return parsed; } catch { return null; }}
/** Persists column widths and order to localStorage. */function saveLayout(widths, order) { localStorage.setItem(STORAGE_KEY, JSON.stringify({ widths, order }));}
const saved = loadLayout();const initialWidths = saved ? saved.widths : DEFAULT_COL_WIDTHS;const initialOrder = saved ? saved.order : null;
const container = document.querySelector('#example1');
const hot = new Handsontable(container, { data, colHeaders: ['SKU', 'Name', 'Category', 'Price ($)', 'Stock', 'Status'], columns: [ { data: 'sku', type: 'text' }, { data: 'name', type: 'text' }, { data: 'category', type: 'text' }, { data: 'price', type: 'numeric', numericFormat: { pattern: '0,0.00' } }, { data: 'stock', type: 'numeric' }, { data: 'status', type: 'text' }, ], colWidths: initialWidths, manualColumnResize: true, manualColumnMove: initialOrder || true, rowHeaders: true, height: 320, width: '100%', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation',
// Capture the new width array after every column resize and persist it. afterColumnResize() { const widths = hot.getColHeader().map((_, visualIndex) => hot.getColWidth(visualIndex) );
saveLayout(widths, getCurrentOrder()); },
// Capture the new column order after every column move and persist it. afterColumnMove(_movedColumns, _finalIndex, _dropIndex, _movePossible, movePerformed) { if (!movePerformed) return; saveLayout(getCurrentWidths(), getCurrentOrder()); },});
/** Returns the current visual-to-physical column order as an array of physical indices. */function getCurrentOrder() { const count = hot.countCols(); const order = [];
for (let visualIndex = 0; visualIndex < count; visualIndex++) { order.push(hot.toPhysicalColumn(visualIndex)); }
return order;}
/** Returns the current column widths in visual order. */function getCurrentWidths() { const count = hot.countCols(); const widths = [];
for (let visualIndex = 0; visualIndex < count; visualIndex++) { widths.push(hot.getColWidth(visualIndex)); }
return widths;}
// Reset button: clear localStorage and restore the default layout.document.querySelector('#reset-layout-btn').addEventListener('click', () => { localStorage.removeItem(STORAGE_KEY);
// Reset column order to the identity sequence [0, 1, 2, 3, 4, 5]. hot.columnIndexMapper.setIndexesSequence(DEFAULT_COL_ORDER);
// Reset each column width through the ManualColumnResize plugin API. const resizePlugin = hot.getPlugin('manualColumnResize');
DEFAULT_COL_WIDTHS.forEach((width, visualIndex) => { resizePlugin.setManualSize(visualIndex, width); });
hot.render();});<div class="example-controls-container"> <div class="controls"> <button id="reset-layout-btn" type="button">Reset layout</button> </div></div><div id="example1"></div>Overview
Difficulty: Beginner Time: ~20 minutes
This tutorial shows how to save column widths and column order to localStorage as the user resizes or reorders columns, then read those values back when the grid initializes. The result is a grid whose layout survives page refreshes.
What You’ll Build
A product-inventory grid with six columns (SKU, Name, Category, Price, Stock, Status) that:
- Saves column widths to
localStorageafter every resize (afterColumnResize). - Saves column order to
localStorageafter every move (afterColumnMove). - Restores the saved layout when the page loads.
- Falls back to default widths and order when no saved data exists, or when the stored data is malformed.
- Provides a Reset layout button that clears the saved state and restores defaults via
hot.updateSettings().
Before you begin
This recipe uses only built-in Handsontable features. No extra dependencies are required.
You should be familiar with:
- Creating a basic Handsontable instance.
- The
manualColumnResizeandmanualColumnMoveoptions.
Step 1 — Define defaults and a storage key
const STORAGE_KEY = 'ht-column-layout-v1';
const DEFAULT_COL_WIDTHS = [90, 200, 120, 90, 70, 110];const DEFAULT_COL_ORDER = [0, 1, 2, 3, 4, 5];What’s happening: STORAGE_KEY is a namespaced key used for every localStorage read and write in this recipe. Namespacing prevents collisions with other data stored by the same page.
DEFAULT_COL_WIDTHS lists the pixel width for each column in its default order. DEFAULT_COL_ORDER is an array of physical column indices — [0, 1, 2, 3, 4, 5] means the visual order matches the source order. Both are used when no saved layout exists and when the user clicks Reset layout.
Why include a version suffix (-v1) in the key? When you change the column count or column schema, old saved data becomes incompatible. Bumping the version suffix (-v2, -v3, …) means the next page load finds nothing under the new key, falls back to defaults, and starts fresh. Old data under the previous key is left to expire naturally.
Step 2 — Read and validate the saved layout
function loadLayout() { try { const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; const parsed = JSON.parse(raw);
if (!Array.isArray(parsed.widths) || !Array.isArray(parsed.order)) return null;
return parsed; } catch { return null; }}What’s happening, step by step:
localStorage.getItem(STORAGE_KEY)returnsnullwhen the key does not exist. The function returnsnullimmediately in that case.JSON.parse()can throw when the stored string is not valid JSON — for example, when the browser truncated the write due to a storage quota error, or when an earlier version of the page stored plain text under the same key. Thetry/catchconverts any parse error into a safenullreturn.- The
Array.isArray()guards reject objects that look like JSON but are missing the expectedwidthsandorderkeys — for example, data written by a different schema version.
Why not trust the stored data directly? localStorage is writable by any script on the page, and its contents can be manually edited in browser DevTools. Defensive validation ensures a corrupt entry does not break the grid initialization.
Step 3 — Write the saved layout
function saveLayout(widths, order) { localStorage.setItem(STORAGE_KEY, JSON.stringify({ widths, order }));}What’s happening: saveLayout serializes both arrays into a single JSON object and writes it under the storage key. Grouping them in one object means a single setItem call — localStorage operations are synchronous and blocking, so minimizing calls reduces the chance of a partial write.
Step 4 — Initialize Handsontable with the saved or default layout
const saved = loadLayout();const initialWidths = saved ? saved.widths : DEFAULT_COL_WIDTHS;const initialOrder = saved ? saved.order : null;
const hot = new Handsontable(container, { data, colHeaders: ['SKU', 'Name', 'Category', 'Price ($)', 'Stock', 'Status'], columns: [ /* ... */ ], colWidths: initialWidths, manualColumnResize: true, manualColumnMove: initialOrder || true, licenseKey: 'non-commercial-and-evaluation', /* hooks wired in the next step */});What’s happening:
colWidths: initialWidthssets the pixel widths for each column at startup. WheninitialWidthsis the saved array, the columns start at exactly the sizes the user last saved.manualColumnResize: trueactivates the resize handle on every column header border, so the user can drag to resize.manualColumnMove: initialOrder || truedeserves attention. WhenmanualColumnMovereceives an array of physical indices, Handsontable treats it as the initial visual-to-physical mapping and re-orders columns accordingly. When it receivestrue, columns start in their default order. Passingnullorfalsewould disable the feature entirely, so the fallback istrue.
Why pass the order to manualColumnMove instead of using columnMapping? The manualColumnMove option is the documented way to specify an initial column order when the plugin is enabled. It reads the array once at initialization, sets up the column index mapper, and after that behaves exactly like manualColumnMove: true.
Step 5 — Capture and save column widths after a resize
afterColumnResize() { const widths = hot.getColHeader().map((_, visualIndex) => hot.getColWidth(visualIndex) );
saveLayout(widths, getCurrentOrder());},What’s happening:
afterColumnResizefires after the user finishes dragging a column border. At that point the column’s new width is already applied.hot.getColHeader()returns the current column headers array. Its length equals the number of columns, so mapping over it produces an index from0tocolCount - 1.hot.getColWidth(visualIndex)returns the current pixel width for that visual column. Widths are read in visual order so the saved array aligns with the visual order at the time of saving.getCurrentOrder()(defined in Step 6) reads the current visual order. Both are saved together so the two arrays always stay in sync.
Why save all widths, not just the resized one? If only the changed column’s width is stored, restoring requires knowing which column was last resized. Storing the full array keeps the saved state self-contained.
Step 6 — Capture and save column order after a move
afterColumnMove(_movedColumns, _finalIndex, _dropIndex, _movePossible, movePerformed) { if (!movePerformed) return; saveLayout(getCurrentWidths(), getCurrentOrder());},What’s happening:
afterColumnMovefires after the user drops a column in a new position. The fifth parameter,movePerformed, isfalsewhen the drop did not actually change the order (for example, the user dropped a column back in the same position). Checking it avoids an unnecessarylocalStoragewrite.getCurrentOrder()andgetCurrentWidths()read the current state at the moment the hook fires, so both values are always fresh.
The helper functions:
function getCurrentOrder() { const count = hot.countCols(); const order = [];
for (let visualIndex = 0; visualIndex < count; visualIndex++) { order.push(hot.toPhysicalColumn(visualIndex)); }
return order;}
function getCurrentWidths() { const count = hot.countCols(); const widths = [];
for (let visualIndex = 0; visualIndex < count; visualIndex++) { widths.push(hot.getColWidth(visualIndex)); }
return widths;}hot.toPhysicalColumn(visualIndex) translates a visual index to the underlying physical index. Physical indices correspond to column positions in the original columns array and do not change when columns are reordered. Saving physical indices means the stored order can always be interpreted as “visual slot N holds column at physical index M”.
Why iterate instead of calling a bulk API? Handsontable does not expose a single method that returns the full visual-to-physical mapping array. The toPhysicalColumn loop is the idiomatic way to derive it.
Step 7 — Wire up the Reset layout button
document.querySelector('#reset-layout-btn').addEventListener('click', () => { localStorage.removeItem(STORAGE_KEY);
// Reset column order to the identity sequence [0, 1, 2, 3, 4, 5]. hot.columnIndexMapper.setIndexesSequence(DEFAULT_COL_ORDER);
// Reset each column width through the ManualColumnResize plugin API. const resizePlugin = hot.getPlugin('manualColumnResize');
DEFAULT_COL_WIDTHS.forEach((width, visualIndex) => { resizePlugin.setManualSize(visualIndex, width); });
hot.render();});What’s happening:
localStorage.removeItem(STORAGE_KEY)deletes the saved entry so the next page load starts from defaults.hot.columnIndexMapper.setIndexesSequence(DEFAULT_COL_ORDER)writes the identity sequence[0, 1, 2, 3, 4, 5]directly into the column index mapper, restoring the default visual order immediately without triggering additional moves.resizePlugin.setManualSize(visualIndex, width)sets each column’s stored width in the ManualColumnResize plugin’s internal map. Because the sequence was reset first, visual and physical indices are in sync at this point.hot.render()repaints the grid to reflect both changes at once.
Why not updateSettings({ colWidths, manualColumnMove })? updateSettings reapplies manualColumnMove by calling moveColumns(array, 0) on the current (already reordered) state — which does not reliably restore the identity sequence. Similarly, colWidths is a core option, not the ManualColumnResize plugin’s internal map, so passing it through updateSettings does not clear the widths the user has already set interactively. The direct plugin APIs bypass these limitations.
How It Works - Complete Flow
- Page loads:
loadLayout()reads and validateslocalStorage. If data exists,initialWidthsandinitialOrderare set from it. Otherwise they fall back toDEFAULT_COL_WIDTHSandnull. - Grid initializes:
colWidthssets pixel widths.manualColumnMovesets the visual order when an array is provided, or enables the feature with the default order whentrue. - User resizes a column:
afterColumnResizefires, reads all column widths and the current order, and writes both tolocalStorage. - User moves a column:
afterColumnMovefires (ifmovePerformedistrue), reads all widths and the new order, and writes both tolocalStorage. - User refreshes the page: Step 1 repeats. The saved layout is found, and the grid initializes with the user’s preferred widths and order.
- User clicks Reset layout:
localStorageentry is removed.hot.columnIndexMapper.setIndexesSequence()restores default column order.resizePlugin.setManualSize()restores default widths per column.hot.render()repaints the grid. The next resize or move writes fresh data.
What you learned
- Use
colWidthsto set initial column widths andmanualColumnMove(with an array of physical indices) to set an initial visual order. - Read all column widths with
hot.getColWidth(visualIndex)and translate visual positions to physical indices withhot.toPhysicalColumn(visualIndex). - Use
afterColumnResizeandafterColumnMoveto detect when the user changes the layout and persist those changes immediately. - Validate data read from
localStoragebefore using it, so malformed or stale entries fall back to defaults gracefully. - Call
hot.columnIndexMapper.setIndexesSequence()andresizePlugin.setManualSize()to reset column order and widths at runtime without recreating the grid.
Next steps
- Extend the recipe to also persist
colHeaderslabel overrides or hidden column state. - If your page has multiple grids, give each grid its own storage key (e.g.,
ht-column-layout-v1-products,ht-column-layout-v1-orders). - Replace
localStoragewith a server-side API call to sync layout preferences across devices and browsers. - Combine this recipe with Build a dynamic column visibility toggle to let users both hide columns and remember their visibility state.