Skip to content

Freeze and unfreeze columns at runtime

In this tutorial, you will freeze and unfreeze columns at runtime using external buttons. You will learn how to call hot.updateSettings with fixedColumnsStart and how frozen columns interact with manual column reordering.

JavaScript
import { useCallback, useRef, useState } from 'react';
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import './example1.css';
registerAllModules();
/* start:skip-in-preview */
const data = [
{ campaign: 'Spring Sale', channel: 'Email', impressions: 120000, clicks: 4800, conversions: 320, cpc: 0.42, revenue: 9600, roi: 2.28 },
{ campaign: 'Summer Push', channel: 'Paid Search', impressions: 85000, clicks: 3100, conversions: 210, cpc: 1.15, revenue: 6300, roi: 1.82 },
{ campaign: 'Back to School', channel: 'Social', impressions: 200000, clicks: 7200, conversions: 540, cpc: 0.31, revenue: 16200, roi: 3.10 },
{ campaign: 'Black Friday', channel: 'Display', impressions: 450000, clicks: 9000, conversions: 720, cpc: 0.65, revenue: 28800, roi: 2.94 },
{ campaign: 'Holiday Deals', channel: 'Email', impressions: 310000, clicks: 11200, conversions: 890, cpc: 0.28, revenue: 35600, roi: 4.12 },
{ campaign: 'New Year Offer', channel: 'Paid Search', impressions: 95000, clicks: 3800, conversions: 290, cpc: 1.22, revenue: 8700, roi: 1.65 },
{ campaign: 'Valentine Push', channel: 'Social', impressions: 140000, clicks: 5600, conversions: 410, cpc: 0.38, revenue: 12300, roi: 2.56 },
{ campaign: 'Spring Relaunch', channel: 'Display', impressions: 175000, clicks: 6300, conversions: 480, cpc: 0.55, revenue: 14400, roi: 2.18 },
];
/* end:skip-in-preview */
const colHeaders = ['Campaign', 'Channel', 'Impressions', 'Clicks', 'Conversions', 'CPC ($)', 'Revenue ($)', 'ROI'];
const columns = [
{ data: 'campaign', type: 'text' },
{ data: 'channel', type: 'text' },
{ data: 'impressions', type: 'numeric', numericFormat: { pattern: '0,0' } },
{ data: 'clicks', type: 'numeric', numericFormat: { pattern: '0,0' } },
{ data: 'conversions', type: 'numeric', numericFormat: { pattern: '0,0' } },
{ data: 'cpc', type: 'numeric', numericFormat: { pattern: '0.00' } },
{ data: 'revenue', type: 'numeric', numericFormat: { pattern: '$0,0' } },
{ data: 'roi', type: 'numeric', numericFormat: { pattern: '0.00' } },
];
const ExampleComponent = () => {
const hotRef = useRef(null);
const [frozenCount, setFrozenCount] = useState(0);
const freezeUpTo = useCallback((n) => {
const hot = hotRef.current?.hotInstance;
const total = hot ? hot.countCols() : colHeaders.length;
setFrozenCount(Math.min(n, total));
}, []);
const unfreezeAll = useCallback(() => {
setFrozenCount(0);
}, []);
const statusText = frozenCount === 0
? 'No columns frozen'
: `${frozenCount} column${frozenCount > 1 ? 's' : ''} frozen`;
return (
<div>
<div className="freeze-controls">
<div className="freeze-controls__freeze-btns">
{colHeaders.map((header, index) => (
<button
key={header}
type="button"
onClick={() => freezeUpTo(index + 1)}
>
Freeze up to &quot;{header}&quot;
</button>
))}
</div>
<div className="freeze-controls__footer">
<button type="button" onClick={unfreezeAll}>Unfreeze all</button>
<span className="freeze-controls__status">{statusText}</span>
</div>
</div>
<HotTable
ref={hotRef}
data={data}
colHeaders={colHeaders}
columns={columns}
fixedColumnsStart={frozenCount}
manualColumnMove={true}
rowHeaders={true}
height="auto"
width="100%"
autoWrapRow={true}
licenseKey="non-commercial-and-evaluation"
/>
</div>
);
};
export default ExampleComponent;
TypeScript
import { useCallback, useRef, useState } from 'react';
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import type { HotTableRef } from '@handsontable/react-wrapper';
import './example1.css';
registerAllModules();
/* start:skip-in-preview */
type CampaignRow = {
campaign: string;
channel: string;
impressions: number;
clicks: number;
conversions: number;
cpc: number;
revenue: number;
roi: number;
};
const data: CampaignRow[] = [
{ campaign: 'Spring Sale', channel: 'Email', impressions: 120000, clicks: 4800, conversions: 320, cpc: 0.42, revenue: 9600, roi: 2.28 },
{ campaign: 'Summer Push', channel: 'Paid Search', impressions: 85000, clicks: 3100, conversions: 210, cpc: 1.15, revenue: 6300, roi: 1.82 },
{ campaign: 'Back to School', channel: 'Social', impressions: 200000, clicks: 7200, conversions: 540, cpc: 0.31, revenue: 16200, roi: 3.10 },
{ campaign: 'Black Friday', channel: 'Display', impressions: 450000, clicks: 9000, conversions: 720, cpc: 0.65, revenue: 28800, roi: 2.94 },
{ campaign: 'Holiday Deals', channel: 'Email', impressions: 310000, clicks: 11200, conversions: 890, cpc: 0.28, revenue: 35600, roi: 4.12 },
{ campaign: 'New Year Offer', channel: 'Paid Search', impressions: 95000, clicks: 3800, conversions: 290, cpc: 1.22, revenue: 8700, roi: 1.65 },
{ campaign: 'Valentine Push', channel: 'Social', impressions: 140000, clicks: 5600, conversions: 410, cpc: 0.38, revenue: 12300, roi: 2.56 },
{ campaign: 'Spring Relaunch', channel: 'Display', impressions: 175000, clicks: 6300, conversions: 480, cpc: 0.55, revenue: 14400, roi: 2.18 },
];
/* end:skip-in-preview */
const colHeaders = ['Campaign', 'Channel', 'Impressions', 'Clicks', 'Conversions', 'CPC ($)', 'Revenue ($)', 'ROI'];
const columns = [
{ data: 'campaign', type: 'text' as const },
{ data: 'channel', type: 'text' as const },
{ data: 'impressions', type: 'numeric' as const, numericFormat: { pattern: '0,0' } },
{ data: 'clicks', type: 'numeric' as const, numericFormat: { pattern: '0,0' } },
{ data: 'conversions', type: 'numeric' as const, numericFormat: { pattern: '0,0' } },
{ data: 'cpc', type: 'numeric' as const, numericFormat: { pattern: '0.00' } },
{ data: 'revenue', type: 'numeric' as const, numericFormat: { pattern: '$0,0' } },
{ data: 'roi', type: 'numeric' as const, numericFormat: { pattern: '0.00' } },
];
const ExampleComponent = () => {
const hotRef = useRef<HotTableRef>(null);
const [frozenCount, setFrozenCount] = useState(0);
const freezeUpTo = useCallback((n: number) => {
const hot = hotRef.current?.hotInstance;
const total = hot ? hot.countCols() : colHeaders.length;
setFrozenCount(Math.min(n, total));
}, []);
const unfreezeAll = useCallback(() => {
setFrozenCount(0);
}, []);
const statusText = frozenCount === 0
? 'No columns frozen'
: `${frozenCount} column${frozenCount > 1 ? 's' : ''} frozen`;
return (
<div>
<div className="freeze-controls">
<div className="freeze-controls__freeze-btns">
{colHeaders.map((header, index) => (
<button
key={header}
type="button"
onClick={() => freezeUpTo(index + 1)}
>
Freeze up to &quot;{header}&quot;
</button>
))}
</div>
<div className="freeze-controls__footer">
<button type="button" onClick={unfreezeAll}>Unfreeze all</button>
<span className="freeze-controls__status">{statusText}</span>
</div>
</div>
<HotTable
ref={hotRef}
data={data}
colHeaders={colHeaders}
columns={columns}
fixedColumnsStart={frozenCount}
manualColumnMove={true}
rowHeaders={true}
height="auto"
width="100%"
autoWrapRow={true}
licenseKey="non-commercial-and-evaluation"
/>
</div>
);
};
export default ExampleComponent;
CSS
.freeze-controls {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.freeze-controls__freeze-btns {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.freeze-controls__footer {
display: flex;
align-items: center;
gap: 8px;
}
.freeze-controls button {
border: 1px solid var(--sl-color-gray-5);
background: var(--sl-color-bg-nav);
color: var(--sl-color-text);
font-size: var(--sl-text-sm);
line-height: 1.2;
padding: 0.4rem 0.75rem;
border-radius: 0;
cursor: pointer;
transition: color 0.15s, background-color 0.15s, border-color 0.15s;
}
.freeze-controls button:hover {
color: var(--sl-color-white);
background: var(--sl-color-gray-6);
}
.freeze-controls button:focus-visible {
outline: 1px solid var(--sl-color-accent);
outline-offset: 1px;
}
.freeze-controls__status {
font-size: var(--sl-text-sm);
font-style: italic;
color: var(--sl-color-gray-2);
}

Overview

Difficulty: Beginner Time: ~15 minutes

This recipe shows how to freeze and unfreeze columns at runtime using hot.updateSettings({ fixedColumnsStart: n }). Frozen columns stay pinned to the left edge of the grid while the rest scroll horizontally. External buttons let users control the freeze boundary without touching the grid configuration directly.

What You’ll Build

  • A row of Freeze up to “Column” buttons — one per column — generated from the column headers array
  • An Unfreeze all button that resets the freeze boundary to zero
  • A status indicator showing how many columns are currently frozen
  • A guard that prevents freezing more columns than are present in the grid

Before you begin

This recipe uses only the built-in Handsontable API. No extra dependencies are required.

The example uses marketing analytics data with eight columns: Campaign, Channel, Impressions, Clicks, Conversions, CPC, Revenue, and ROI. With many columns the grid scrolls horizontally, making frozen columns genuinely useful — the identifier columns stay visible while metric columns scroll.

Step 1 — Set up the grid with fixedColumnsStart and manualColumnMove

const hot = new Handsontable(container, {
data,
colHeaders,
columns: [
{ data: 'campaign', type: 'text' },
{ data: 'channel', type: 'text' },
{ data: 'impressions', type: 'numeric', numericFormat: { pattern: '0,0' } },
{ data: 'clicks', type: 'numeric', numericFormat: { pattern: '0,0' } },
{ data: 'conversions', type: 'numeric', numericFormat: { pattern: '0,0' } },
{ data: 'cpc', type: 'numeric', numericFormat: { pattern: '0.00' } },
{ data: 'revenue', type: 'numeric', numericFormat: { pattern: '$0,0' } },
{ data: 'roi', type: 'numeric', numericFormat: { pattern: '0.00' } },
],
fixedColumnsStart: 0,
manualColumnMove: true,
rowHeaders: true,
height: 'auto',
width: '100%',
autoWrapRow: true,
licenseKey: 'non-commercial-and-evaluation',
});

What’s happening: fixedColumnsStart: 0 is the initial value — no columns are frozen on load. manualColumnMove: true allows users to drag columns into different positions. Both options work together: after the user reorders columns, the freeze boundary still refers to the current visual order (the leftmost n columns in their current positions are frozen).

Why set fixedColumnsStart: 0 explicitly? The default is 0, but declaring it makes the initial state visible in the config and makes the runtime change more explicit to anyone reading the code.

Step 2 — Track state and apply the freeze boundary

let frozenCount = 0;
function freezeUpTo(n) {
const total = hot.countCols();
frozenCount = Math.min(n, total);
hot.updateSettings({ fixedColumnsStart: frozenCount });
updateStatus();
}

What’s happening: frozenCount is the single piece of mutable state in this recipe. freezeUpTo(n) clamps the requested freeze count to the actual number of columns (hot.countCols()) and then calls hot.updateSettings(). Clamping handles the edge case where a freeze button is clicked after columns have been hidden or removed.

Why use hot.updateSettings() instead of modifying the config object directly? hot.updateSettings() is the documented way to change grid settings at runtime. It triggers a full re-render and keeps Handsontable’s internal state consistent. Modifying a config object directly has no effect on the rendered grid.

Why Math.min(n, total)? If fixedColumnsStart exceeds the number of rendered columns, Handsontable clamps it internally — but being explicit in application code prevents confusion and makes the guard visible.

Step 3 — Generate freeze buttons from the column headers

const colHeaders = ['Campaign', 'Channel', 'Impressions', 'Clicks', 'Conversions', 'CPC ($)', 'Revenue ($)', 'ROI'];
colHeaders.forEach((header, index) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = `Freeze up to "${header}"`;
btn.dataset.colIndex = String(index);
btn.addEventListener('click', () => {
freezeUpTo(index + 1);
});
controlsContainer.appendChild(btn);
});

What’s happening: One button is generated for each column header. Clicking the button for column at index i calls freezeUpTo(i + 1), which freezes columns 0 through i inclusive. Generating buttons from the same colHeaders array ensures the button labels and column positions are always in sync — adding or renaming a column in the config automatically updates the button list.

Why index + 1? fixedColumnsStart is a count, not an index. To freeze the column at visual index i, you set the count to i + 1.

Step 4 — Add the Unfreeze all button

document.querySelector('#unfreeze-btn').addEventListener('click', () => {
frozenCount = 0;
hot.updateSettings({ fixedColumnsStart: 0 });
updateStatus();
});

What’s happening: Setting fixedColumnsStart: 0 releases all frozen columns. The grid re-renders without any pinned column overlay. The status indicator is updated to “No columns frozen”.

Step 5 — Show a frozen column count indicator

function updateStatus() {
statusEl.textContent = frozenCount === 0
? 'No columns frozen'
: `${frozenCount} column${frozenCount > 1 ? 's' : ''} frozen`;
}

What’s happening: updateStatus() is called after every freeze or unfreeze action. It reads frozenCount — not the grid setting — because frozenCount is already clamped and reflects the actual frozen count. The status message uses a simple plural rule to display “1 column frozen” or “3 columns frozen”.

How It Works - Complete Flow

  1. Page load: The grid initializes with fixedColumnsStart: 0. No columns are pinned. The status reads “No columns frozen”. Freeze buttons are generated from colHeaders.
  2. User clicks “Freeze up to ‘Channel’”: freezeUpTo(2) is called. frozenCount becomes 2. hot.updateSettings({ fixedColumnsStart: 2 }) pins the first two columns (Campaign and Channel). The status reads “2 columns frozen”.
  3. User drags a column: Because manualColumnMove: true is set, the user can reorder columns by dragging. After reordering, the frozen count still applies to the current leftmost n columns — whichever columns are now in positions 0 and 1. This is the key caveat: frozen columns are always the leftmost n columns in their current visual order, not a fixed set of named columns.
  4. User clicks “Unfreeze all”: frozenCount resets to 0. hot.updateSettings({ fixedColumnsStart: 0 }) removes all frozen columns. The status reads “No columns frozen”.

What you learned

  • Set fixedColumnsStart: n in the initial config or via hot.updateSettings() to pin the leftmost n columns.
  • Call hot.updateSettings({ fixedColumnsStart: 0 }) to unfreeze all columns.
  • Generate freeze buttons programmatically from the colHeaders array so labels stay in sync with the grid configuration.
  • fixedColumnsStart is a count from the left edge in current visual order — not a named column reference. After column reordering, the frozen set changes accordingly.
  • Clamp the freeze count with Math.min(n, hot.countCols()) to handle edge cases where the requested count exceeds the number of visible columns.

Next steps

  • Combine this pattern with the column visibility toggle recipe to build a full column-management toolbar.
  • Persist the frozenCount value to localStorage using the afterUpdateSettings hook so the freeze state survives a page refresh.
  • Replace the individual freeze buttons with a single numeric input (<input type="number">) to let users type a freeze count directly.