Skip to content

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.

JavaScript
import { useCallback, useRef } from 'react';
import { HotTable } from '@handsontable/react-wrapper';
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 */
const DEFAULT_COL_WIDTHS = [90, 200, 120, 90, 70, 110];
const DEFAULT_COL_ORDER = [0, 1, 2, 3, 4, 5];
const 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' },
];
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;
}
}
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 ExampleComponent = () => {
const hotRef = useRef(null);
const getCurrentOrder = useCallback(() => {
const hot = hotRef.current?.hotInstance;
if (!hot) return DEFAULT_COL_ORDER;
const count = hot.countCols();
const order = [];
for (let i = 0; i < count; i++) {
order.push(hot.toPhysicalColumn(i));
}
return order;
}, []);
const getCurrentWidths = useCallback(() => {
const hot = hotRef.current?.hotInstance;
if (!hot) return DEFAULT_COL_WIDTHS;
const count = hot.countCols();
const widths = [];
for (let i = 0; i < count; i++) {
widths.push(hot.getColWidth(i));
}
return widths;
}, []);
const handleAfterColumnResize = useCallback(() => {
const hot = hotRef.current?.hotInstance;
if (!hot) return;
const widths = hot.getColHeader().map((_, visualIndex) => hot.getColWidth(visualIndex));
saveLayout(widths, getCurrentOrder());
}, [getCurrentOrder]);
const handleAfterColumnMove = useCallback(
(_movedColumns, _finalIndex, _dropIndex, _movePossible, movePerformed) => {
if (!movePerformed) return;
saveLayout(getCurrentWidths(), getCurrentOrder());
},
[getCurrentWidths, getCurrentOrder]
);
const handleReset = useCallback(() => {
const hot = hotRef.current?.hotInstance;
if (!hot) return;
localStorage.removeItem(STORAGE_KEY);
hot.columnIndexMapper.setIndexesSequence(DEFAULT_COL_ORDER);
const resizePlugin = hot.getPlugin('manualColumnResize');
DEFAULT_COL_WIDTHS.forEach((width, visualIndex) => {
resizePlugin.setManualSize(visualIndex, width);
});
hot.render();
}, []);
return (
<div>
<div className="example-controls-container">
<div className="controls">
<button type="button" onClick={handleReset}>Reset layout</button>
</div>
</div>
<HotTable
ref={hotRef}
data={data}
colHeaders={['SKU', 'Name', 'Category', 'Price ($)', 'Stock', 'Status']}
columns={COLUMNS}
colWidths={initialWidths}
manualColumnResize={true}
manualColumnMove={initialOrder || true}
rowHeaders={true}
height={320}
width="100%"
autoWrapRow={true}
licenseKey="non-commercial-and-evaluation"
afterColumnResize={handleAfterColumnResize}
afterColumnMove={handleAfterColumnMove}
/>
</div>
);
};
export default ExampleComponent;
TypeScript
import { useCallback, useRef } from 'react';
import { HotTable, HotTableRef } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import type { ColumnSettings } from 'handsontable/settings';
registerAllModules();
const STORAGE_KEY = 'ht-column-layout-v1';
/* start:skip-in-preview */
type ProductRow = {
sku: string;
name: string;
category: string;
price: number;
stock: number;
status: string;
};
const data: ProductRow[] = [
{ 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 */
const DEFAULT_COL_WIDTHS: number[] = [90, 200, 120, 90, 70, 110];
const DEFAULT_COL_ORDER: number[] = [0, 1, 2, 3, 4, 5];
const COLUMNS: ColumnSettings[] = [
{ 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' },
];
type SavedLayout = { widths: number[]; order: number[] };
function loadLayout(): SavedLayout | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed !== 'object' ||
parsed === null ||
!Array.isArray((parsed as SavedLayout).widths) ||
!Array.isArray((parsed as SavedLayout).order)
) {
return null;
}
return parsed as SavedLayout;
} catch {
return null;
}
}
function saveLayout(widths: number[], order: number[]): void {
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 ExampleComponent = () => {
const hotRef = useRef<HotTableRef>(null);
const getCurrentOrder = useCallback((): number[] => {
const hot = hotRef.current?.hotInstance;
if (!hot) return DEFAULT_COL_ORDER;
const count = hot.countCols();
const order: number[] = [];
for (let i = 0; i < count; i++) {
order.push(hot.toPhysicalColumn(i) as number);
}
return order;
}, []);
const getCurrentWidths = useCallback((): number[] => {
const hot = hotRef.current?.hotInstance;
if (!hot) return DEFAULT_COL_WIDTHS;
const count = hot.countCols();
const widths: number[] = [];
for (let i = 0; i < count; i++) {
widths.push(hot.getColWidth(i) as number);
}
return widths;
}, []);
const handleAfterColumnResize = useCallback(() => {
const hot = hotRef.current?.hotInstance;
if (!hot) return;
const widths = (hot.getColHeader() as string[]).map((_, visualIndex) =>
hot.getColWidth(visualIndex) as number
);
saveLayout(widths, getCurrentOrder());
}, [getCurrentOrder]);
const handleAfterColumnMove = useCallback(
(
_movedColumns: number[],
_finalIndex: number,
_dropIndex: number | undefined,
_movePossible: boolean,
movePerformed: boolean
) => {
if (!movePerformed) return;
saveLayout(getCurrentWidths(), getCurrentOrder());
},
[getCurrentWidths, getCurrentOrder]
);
const handleReset = useCallback(() => {
const hot = hotRef.current?.hotInstance;
if (!hot) return;
localStorage.removeItem(STORAGE_KEY);
hot.columnIndexMapper.setIndexesSequence(DEFAULT_COL_ORDER);
const resizePlugin = hot.getPlugin('manualColumnResize');
DEFAULT_COL_WIDTHS.forEach((width, visualIndex) => {
resizePlugin.setManualSize(visualIndex, width);
});
hot.render();
}, []);
return (
<div>
<div className="example-controls-container">
<div className="controls">
<button type="button" onClick={handleReset}>Reset layout</button>
</div>
</div>
<HotTable
ref={hotRef}
data={data}
colHeaders={['SKU', 'Name', 'Category', 'Price ($)', 'Stock', 'Status']}
columns={COLUMNS}
colWidths={initialWidths}
manualColumnResize={true}
manualColumnMove={initialOrder || true}
rowHeaders={true}
height={320}
width="100%"
autoWrapRow={true}
licenseKey="non-commercial-and-evaluation"
afterColumnResize={handleAfterColumnResize}
afterColumnMove={handleAfterColumnMove}
/>
</div>
);
};
export default ExampleComponent;

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 localStorage after every resize (afterColumnResize).
  • Saves column order to localStorage after 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 manualColumnResize and manualColumnMove options.

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:

  1. localStorage.getItem(STORAGE_KEY) returns null when the key does not exist. The function returns null immediately in that case.
  2. 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. The try/catch converts any parse error into a safe null return.
  3. The Array.isArray() guards reject objects that look like JSON but are missing the expected widths and order keys — 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: initialWidths sets the pixel widths for each column at startup. When initialWidths is the saved array, the columns start at exactly the sizes the user last saved.
  • manualColumnResize: true activates the resize handle on every column header border, so the user can drag to resize.
  • manualColumnMove: initialOrder || true deserves attention. When manualColumnMove receives an array of physical indices, Handsontable treats it as the initial visual-to-physical mapping and re-orders columns accordingly. When it receives true, columns start in their default order. Passing null or false would disable the feature entirely, so the fallback is true.

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:

  • afterColumnResize fires 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 from 0 to colCount - 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:

  • afterColumnMove fires after the user drops a column in a new position. The fifth parameter, movePerformed, is false when the drop did not actually change the order (for example, the user dropped a column back in the same position). Checking it avoids an unnecessary localStorage write.
  • getCurrentOrder() and getCurrentWidths() 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:

  1. localStorage.removeItem(STORAGE_KEY) deletes the saved entry so the next page load starts from defaults.
  2. 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.
  3. 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.
  4. 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

  1. Page loads: loadLayout() reads and validates localStorage. If data exists, initialWidths and initialOrder are set from it. Otherwise they fall back to DEFAULT_COL_WIDTHS and null.
  2. Grid initializes: colWidths sets pixel widths. manualColumnMove sets the visual order when an array is provided, or enables the feature with the default order when true.
  3. User resizes a column: afterColumnResize fires, reads all column widths and the current order, and writes both to localStorage.
  4. User moves a column: afterColumnMove fires (if movePerformed is true), reads all widths and the new order, and writes both to localStorage.
  5. User refreshes the page: Step 1 repeats. The saved layout is found, and the grid initializes with the user’s preferred widths and order.
  6. User clicks Reset layout: localStorage entry 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 colWidths to set initial column widths and manualColumnMove (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 with hot.toPhysicalColumn(visualIndex).
  • Use afterColumnResize and afterColumnMove to detect when the user changes the layout and persist those changes immediately.
  • Validate data read from localStorage before using it, so malformed or stale entries fall back to defaults gracefully.
  • Call hot.columnIndexMapper.setIndexesSequence() and resizePlugin.setManualSize() to reset column order and widths at runtime without recreating the grid.

Next steps

  • Extend the recipe to also persist colHeaders label 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 localStorage with 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.