Two-way sync with Google Sheets
In this tutorial, you will put a Handsontable DataGrid on top of a Google Sheet without moving the data. The grid reads the sheet, writes the user’s edits back, and picks up edits other people make in Google Sheets. The app runs in the browser only: the user signs in with Google, and the grid calls the Google Sheets API directly, with no backend.
The grid above syncs with the sheet next to it. Edit a cell in the grid, and the sheet shows it within a second. Edit the sheet, or select Colleague edits a cell, and the grid picks the change up on its next poll. Select Sort sheet by Product or Insert blank row, then edit the grid: the edit still lands in the right row. The log under the panels lists every API call, and the toolbar counts them against the quota.
Note: the demo above simulates the Google Sheets API in the page, so it works without a Google account. The recipe code calls the real API: to run it, you need your own Google Cloud project, OAuth client ID, and spreadsheet, as set up in Step 1.
Overview
Difficulty: Intermediate
Time: ~30 minutes
Stack: Google Sheets API v4, Google Identity Services, Handsontable, Vite, TypeScript
A grid on top of a shared sheet breaks in predictable ways if it syncs naively:
- Someone sorts the sheet, and the grid’s next edit lands in the wrong row.
- Two people edit the same cell, and the later write silently overwrites the earlier one.
- A change from the sheet arrives while the user is typing, and wipes what they typed.
- Every keystroke becomes a request, and the app runs into the API quota.
- A user types
=SUM(A:A), and the sheet turns it into a formula.
This recipe handles each of these. Rows are matched by an ID column, never by position. Each edit is checked against the sheet before it’s written. Remote changes wait until the editor closes. Edits are batched, and values are written as plain values.
What you’ll build
- A sign-in button that gets a short-lived access token from Google, with no client secret and no server.
- A grid whose columns come from the sheet’s first row, with a hidden
idcolumn. - Edits in the grid that reach the sheet within a second, in one request per batch.
- Edits in Google Sheets that reach the grid within about 5 seconds.
- Rows added and removed in either place.
- Conflict detection: if someone changed a cell in the sheet after the grid last read it, the grid keeps their value and shows a toast.
- Retries with exponential backoff when Google returns
429 Too Many Requestsor a server error, and a request counter against the quota.
Before you begin
| Requirement | Version |
|---|---|
| Node.js | 20 or later |
handsontable | 18.1 or later |
| Vite | any current version |
| A Google account | with access to Google Cloud console |
Create the app:
npm create vite@latest sheets-grid -- --template vanilla-tscd sheets-gridnpm install handsontableSet up Google Cloud and the sheet
The browser needs an OAuth client ID to ask the user for access, and the Sheets API must be enabled for the project the ID belongs to.
- In the Google Cloud console, create a project, or pick an existing one.
- Go to APIs & Services > Library, find Google Sheets API, and select Enable.
- Go to APIs & Services > OAuth consent screen (Google Auth Platform in newer versions of the console) and configure it. If your Google Workspace allows it, pick the Internal user type, so anyone in your organization can sign in. Otherwise pick External, keep the app in Testing mode, and add each tester’s Google account as a test user.
- Go to APIs & Services > Credentials > Create credentials > OAuth client ID. Pick Web application, and under Authorized JavaScript origins add
http://localhost:5173, the address Vite serves the app on. The origin must match exactly:http://127.0.0.1:5173is a different origin. The token flow needs no redirect URI. Copy the client ID.
Then prepare the sheet:
- Create a Google Sheet. Put the column names in row 1, and name one column
id. - Give every row a unique value in the
idcolumn. Any text works, for example1,2,3, or a UUID. - Copy the spreadsheet ID from its URL:
https://docs.google.com/spreadsheets/d/<spreadsheet-id>/edit.
Create
.env.localin the app folder:Terminal window VITE_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.comVITE_SPREADSHEET_ID=your-spreadsheet-idVITE_SHEET_NAME=Sheet1The client ID isn’t a secret: it identifies your app to Google, and Google only accepts it from the origins you listed. Keep
.env.localout of version control anyway, so each developer uses their own sheet.Add the configuration
Create
src/config.ts:TypeScript // Values come from .env.local. The OAuth client ID identifies your// app to Google; it is not a secret, and the browser needs it.export const CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string;export const SPREADSHEET_ID = import.meta.env.VITE_SPREADSHEET_ID as string;export const SHEET_NAME = (import.meta.env.VITE_SHEET_NAME as string | undefined) ?? 'Sheet1';// The column that holds each row's stable ID. Rows are matched by this value, never by// position, so sorting or inserting rows in Google Sheets can't send an edit to the wrong row.export const ID_HEADER = 'id';// 12 reads a minute, well under the Sheets API limit of 60 per user per minute.export const POLL_MS = 5_000;What’s happening:
ID_HEADERnames the column that identifies rows, andPOLL_MSsets how often the grid reads the sheet. Every 5 seconds is 12 reads a minute. The Sheets API allows 60 read requests and 60 write requests a minute per user per project, so the grid stays well under the limit even with the extra reads its writes make.Sign the user in
Create
src/auth.ts:TypeScript import { CLIENT_ID } from './config';// The parts of the Google Identity Services library this app uses. The library loads from// https://accounts.google.com/gsi/client (see index.html); Google doesn't support self-hosting it.interface TokenResponse {access_token: string;expires_in: number;error?: string;}interface TokenClient {requestAccessToken(overrides?: { prompt?: string }): void;}declare global {interface Window {google?: {accounts: {oauth2: {initTokenClient(config: {client_id: string;scope: string;callback: (response: TokenResponse) => void;error_callback?: (error: { type: string }) => void;}): TokenClient;};};};}}// Read and write access to the user's spreadsheets.const SCOPE = 'https://www.googleapis.com/auth/spreadsheets';let client: TokenClient | null = null;// Opens Google's consent popup and resolves with an access token. Call it from a click// handler: browsers block the popup otherwise. The token is short-lived and there is no// refresh token in this flow, so when it expires the user signs in again.export function signIn(): Promise<string> {return new Promise((resolve, reject) => {const oauth2 = window.google?.accounts.oauth2;if (!oauth2) {reject(new Error('Google Identity Services has not loaded yet.'));return;}client = oauth2.initTokenClient({client_id: CLIENT_ID,scope: SCOPE,callback: (response) => (response.error ? reject(new Error(response.error)) : resolve(response.access_token)),error_callback: (error) => reject(new Error(`Sign-in failed: ${error.type}`)),});client.requestAccessToken();});}What’s happening:
- The token model needs no server.
initTokenClientfrom Google Identity Services opens Google’s consent popup and returns an access token to the page. There is no client secret and no refresh token, so nothing sensitive lives in the browser beyond the token itself. - Sign-in must come from a click. Browsers block popups that a page opens on its own, so
signInruns in the button’s click handler. - Ignore the popup warning in the console. During sign-in, the browser console can log
Cross-Origin-Opener-Policy policy would block the window.closed call. Google’s library checks whether its popup has closed, and the browser blocks that check. Sign-in still works. - Tokens expire. An access token lasts about an hour. When it expires, the API returns
401, and the app asks the user to sign in again. - The
spreadsheetsscope covers every sheet the user can open. That is what lets the app open a sheet it didn’t create. The narrowerdrive.filescope only covers files the app created or the user picked with the Google Picker. See Production considerations.
- The token model needs no server.
Call the Sheets API
Create
src/sheets.ts:TypeScript import { SHEET_NAME, SPREADSHEET_ID } from './config';export type Cell = string | number | boolean;const API = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(SPREADSHEET_ID)}`;const MAX_TRIES = 5;let accessToken = '';let sheetId: number | null = null;// Request timestamps from the last minute, for the quota readout.const requestLog: number[] = [];export class SessionExpiredError extends Error {}// A write that hit the quota (429) or a server error (5xx). Run the whole task again with `retrying`.export class RetryableError extends Error {}export function setAccessToken(token: string) {accessToken = token;}export function requestsLastMinute() {const cutoff = Date.now() - 60_000;while (requestLog.length > 0 && requestLog[0] < cutoff) {requestLog.shift();}return requestLog.length;}const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));// Truncated exponential backoff with jitter, as Google recommends: about 1, 2, 4, and 8 seconds.const backoff = (attempt: number) => Math.min(2 ** attempt * 1000 + Math.random() * 1000, 32_000);// Every Sheets API call goes through here. A read that hits the quota (429) or a server error// (5xx) waits and tries again. A write doesn't: it addresses rows by number, and rows can move// in the sheet while it waits, so it throws RetryableError instead.async function sheetsFetch<T>(path: string, init: RequestInit = {}): Promise<T> {const isRead = (init.method ?? 'GET') === 'GET';for (let attempt = 0; ; attempt++) {requestLog.push(Date.now());const response = await fetch(`${API}${path}`, {...init,headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },});if (response.status === 401) {throw new SessionExpiredError('Your Google session expired. Sign in again.');}if (response.status === 429 || response.status >= 500) {if (!isRead) {throw new RetryableError(`Sheets API error ${response.status}`);}if (attempt < MAX_TRIES - 1) {await sleep(backoff(attempt));continue;}}if (!response.ok) {const body = await response.json().catch(() => null);throw new Error(body?.error?.message ?? `Sheets API error ${response.status}`);}return response.json() as Promise<T>;}}// Runs a write task, and runs it again with backoff when a write in it throws RetryableError. The// task reads the sheet before it writes, so each attempt finds the rows where they are now.export async function retrying(task: () => Promise<void>) {for (let attempt = 0; ; attempt++) {try {await task();return;} catch (error) {if (!(error instanceof RetryableError) || attempt >= MAX_TRIES - 1) {throw error;}await sleep(backoff(attempt));}}}// 'Sheet1' -> "'Sheet1'": quoting makes names with spaces or apostrophes safe in a range.const quotedSheet = `'${SHEET_NAME.replace(/'/g, "''")}'`;// 0 -> A, 25 -> Z, 26 -> AA.function columnLetter(index: number): string {let letters = '';for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) {letters = String.fromCharCode(65 + ((n - 1) % 26)) + letters;}return letters;}// Reads the whole tab. UNFORMATTED_VALUE returns numbers as numbers; FORMATTED_STRING// returns dates as the text the sheet shows instead of serial numbers.export async function readSheet(): Promise<Cell[][]> {const range = encodeURIComponent(quotedSheet);try {const result = await sheetsFetch<{ values?: Cell[][] }>(`/values/${range}?valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING`,);return result.values ?? [];} catch (error) {// A tab that doesn't exist comes back as "Unable to parse range". Name the tabs instead.if (error instanceof Error && error.message.startsWith('Unable to parse range')) {const tabs = await listTabs();throw new Error(`No tab named "${SHEET_NAME}". Tabs in this spreadsheet: ${tabs.map(({ title }) => title).join(', ')}.`);}throw error;}}// Writes single cells in one atomic request. `sheetRow` is the 1-based row in the sheet.// RAW stores each value exactly as sent: text that starts with `=` stays text and never// becomes a formula.export async function writeCells(cells: { sheetRow: number; column: number; value: Cell }[]) {if (cells.length === 0) {return;}await sheetsFetch('/values:batchUpdate', {method: 'POST',body: JSON.stringify({valueInputOption: 'RAW',data: cells.map(({ sheetRow, column, value }) => ({range: `${quotedSheet}!${columnLetter(column)}${sheetRow}`,values: [[value]],})),}),});}// Adds rows after the table that starts at A1. A blank row ends that table, so rows can land// above it instead of at the bottom; the sync matches rows by ID, so that is fine.export async function appendRows(rows: Cell[][]) {if (rows.length === 0) {return;}const range = encodeURIComponent(`${quotedSheet}!A1`);await sheetsFetch(`/values/${range}:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS`, {method: 'POST',body: JSON.stringify({ values: rows }),});}// The spreadsheet's tabs, with the numeric IDs the values API doesn't use.async function listTabs() {const result = await sheetsFetch<{ sheets: { properties: { sheetId: number; title: string } }[] }>('?fields=sheets.properties(sheetId,title)',);return result.sheets.map(({ properties }) => properties);}// Deleting rows needs the tab's numeric ID.async function getSheetId(): Promise<number> {if (sheetId === null) {const tab = (await listTabs()).find(({ title }) => title === SHEET_NAME);if (!tab) {throw new Error(`No tab named "${SHEET_NAME}" in the spreadsheet.`);}sheetId = tab.sheetId;}return sheetId;}// Deletes whole rows (1-based sheet row numbers). Bottom-up, so earlier deletions don't// shift the rows still to be deleted.export async function deleteRows(sheetRows: number[]) {if (sheetRows.length === 0) {return;}const id = await getSheetId();await sheetsFetch(':batchUpdate', {method: 'POST',body: JSON.stringify({requests: [...sheetRows].sort((a, b) => b - a).map((row) => ({deleteDimension: { range: { sheetId: id, dimension: 'ROWS', startIndex: row - 1, endIndex: row } },})),}),});}What’s happening:
- A missing tab is named. If the spreadsheet has no tab called
VITE_SHEET_NAME, the API answersUnable to parse range.readSheetturns that into a message that lists the tabs the spreadsheet does have. - One wrapper for every request.
sheetsFetchadds the bearer token. A read that gets429or a5xxresponse is retried with truncated exponential backoff, as Google recommends: about 1, 2, 4, and 8 seconds, plus random jitter, capped at 32 seconds, and at most five tries. A write isn’t retried on its own: it addresses rows by number, and rows can move in the sheet during the wait. It throwsRetryableError, andretryingruns the whole write task again, read included, so each try finds the rows where they are. A401throwsSessionExpiredErrorinstead of retrying. - Numbers come back as numbers.
readSheetasks forUNFORMATTED_VALUE, so a price is12.5, not"$12.50", and the grid can sort and edit it as a number.dateTimeRenderOption=FORMATTED_STRINGreturns dates as the text the sheet shows, instead of serial numbers. RAWkeeps text as text.writeCellssendsvalueInputOption: 'RAW', so the sheet stores each value exactly as sent. WithUSER_ENTERED, the sheet would parse it the way it parses typing, and a value that starts with=would become a formula.- One request per batch.
values:batchUpdatewrites any number of cells in one request, and either all of them are written or none is. It counts as one request against the quota. - Appended rows go after the first table Google finds.
values:appendlooks for a table starting atA1and inserts the new rows right after it. A blank row ends that table, so in a sheet with a blank row in the middle, a row added in the grid lands above the blank row instead of at the bottom. The sync matches rows by ID, so the position doesn’t matter to it. - Rows are deleted bottom-up. Deleting a row shifts every row below it up by one.
deleteRowssorts the row numbers from the bottom, so each deletion leaves the rows still to delete where they were. - Sheet names are quoted.
quotedSheetwraps the tab name in single quotes, so a name with a space or an apostrophe still works in a range.
- A missing tab is named. If the spreadsheet has no tab called
Sync the sheet and the grid
Create
src/sync.ts:TypeScript import type Handsontable from 'handsontable/base';import type { CellChange, ChangeSource } from 'handsontable/settings';import { ID_HEADER, POLL_MS } from './config';import { createGrid } from './grid';import { appendRows, deleteRows, readSheet, retrying, SessionExpiredError, writeCells, type Cell } from './sheets';// Changes the sync makes itself. Only a user's change goes back to the sheet.const OWN_SOURCES = new Set<string>(['sync', 'loadData', 'updateData']);const EDIT_DEBOUNCE_MS = 300;const COLUMNS_CHANGED = 'The sheet\'s columns changed. Reload the page to pick them up.';// A value the grid sent, or is about to send, to the sheet. writtenAt is set once the sheet// has it.interface Pending {value: Cell;writtenAt?: number;}interface Edit {id: string;column: number;base: Cell;value: Cell;pending: Pending;}// Sheets leaves out empty cells at the end of a row, and an empty grid cell is null.// Both become '' so an untouched empty cell compares equal on both sides.const normalize = (value: unknown): Cell => (value === null || value === undefined ? '' : (value as Cell));const key = (id: string, column: number) => `${id}\u0000${column}`;export function startSync(container: HTMLElement, onStatus: (text: string, isError?: boolean) => void) {let hot: Handsontable | null = null;let header: string[] = [];let idColumn = -1;// The grid's data. Handsontable keeps a reference to it, so a physical row index is a// position in this array.let rows: Cell[][] = [];const indexById = new Map<string, number>();// Cells the grid is writing. A read that started before the write landed must not overwrite// them; a read that started after it is the sheet's word, even if someone changed the cell.const pendingCells = new Map<string, Pending>();// Rows added or removed in the grid whose change hasn't reached the sheet yet.const pendingAppends = new Set<string>();const pendingDeletes = new Set<string>();// Set once the sheet's first row no longer matches the grid's columns. From then on, nothing// syncs: a write by column index could land in the wrong column.let columnsChanged = false;// IDs that more than one sheet row has. Each is reported once.const warnedDuplicates = new Set<string>();// Set while polls fail, so an outage shows one toast, not one every POLL_MS.let pollFailing = false;let editBatch = new Map<string, Edit>();let editTimer = 0;let pollTimer = 0;// All writes run one after another, so an append lands before an edit to that row.let writeQueue = Promise.resolve();// The message can quote a sheet value, so it goes in as text, never as HTML.function notify(title: string, message: string) {const text = document.createElement('span');text.textContent = message;hot?.getPlugin('notification').showMessage({ variant: 'warning', title, message: text });}// Clears an edit's pending entry, unless a newer edit of the same cell replaced it.function settle(edit: Edit) {if (pendingCells.get(key(edit.id, edit.column)) === edit.pending) {pendingCells.delete(key(edit.id, edit.column));}}function reindex() {indexById.clear();rows.forEach((row, index) => indexById.set(idOf(row), index));}// Splits a sheet read into the header and the data rows. Each row is padded to the header's// width and keeps its 1-based sheet row number, taken before blank rows are skipped, so a// blank row in the middle of the sheet can't shift a write to the wrong row.function parse(values: Cell[][]) {const [first = [], ...rest] = values;const names = first.map(String);const body: { sheetRow: number; row: Cell[] }[] = [];rest.forEach((raw, index) => {const row = Array.from({ length: names.length }, (_, column) => normalize(raw[column]));if (row.some((value) => value !== '')) {body.push({ sheetRow: index + 2, row });}});return { names, body };}// A row the grid just created holds null, which normalize turns into ''.const idOf = (row: Cell[]) => String(normalize(row[idColumn]));// The first sheet row with each ID. A row copied in Google Sheets with its ID is left out, with a// warning, so an edit or a delete in the grid reaches one sheet row only.function byId(body: { sheetRow: number; row: Cell[] }[]) {const entries = new Map<string, { sheetRow: number; row: Cell[] }>();for (const entry of body) {const id = idOf(entry.row);if (id === '') {continue;}if (!entries.has(id)) {entries.set(id, entry);} else if (hot && !warnedDuplicates.has(id)) {warnedDuplicates.add(id);notify('Duplicate ID', `More than one row in the sheet has the ID "${id}". Only the first one syncs.`);}}return entries;}function checkColumns(names: string[]) {if (columnsChanged || names.join('\u0000') !== header.join('\u0000')) {columnsChanged = true;throw new Error(COLUMNS_CHANGED);}}// Sorting and filtering don't re-run when data changes, so re-apply them after rows are// added or removed. New rows then land in sorted position and filtered-out rows stay hidden.function reapplyView() {if (!hot) {return;}const filters = hot.getPlugin('filters');const sorting = hot.getPlugin('columnSorting');const sortConfig = sorting.getSortConfig();if (filters.exportConditions().length > 0) {filters.filter();}if (Array.isArray(sortConfig) && sortConfig.length > 0) {sorting.sort(sortConfig);}}// Applies a fresh read of the sheet to the grid. readStartedAt is when the read was sent.function applyRemote(values: Cell[][], readStartedAt: number) {const { names, body } = parse(values);if (!hot) {header = names;idColumn = header.indexOf(ID_HEADER);if (idColumn === -1) {throw new Error(`The first row of the sheet needs a column named "${ID_HEADER}".`);}// A row typed into the sheet without an ID isn't synced. Give it one there.rows = [...byId(body).values()].map(({ row }) => row);reindex();hot = createGrid(container, header, rows, idColumn);attachHooks(hot);return;}checkColumns(names);// Setting a cell's source data refreshes an open editor and would wipe what the user is// typing. Skip this round; the next poll catches up.if (hot.getActiveEditor()?.isOpened()) {return;}const remoteIds = new Set<string>();const cellChanges: [number, number, Cell][] = [];let structural = false;for (const [id, { row: remoteRow }] of byId(body)) {remoteIds.add(id);pendingAppends.delete(id);if (pendingDeletes.has(id)) {continue;}const index = indexById.get(id);if (index === undefined) {rows.push(remoteRow);structural = true;continue;}remoteRow.forEach((value, column) => {const pending = pendingCells.get(key(id, column));if (pending) {const landed = pending.writtenAt !== undefined && readStartedAt > pending.writtenAt;if (!landed && pending.value !== value) {return; // This read predates the grid's write. Keep the grid's value.}pendingCells.delete(key(id, column));}if (rows[index][column] !== value) {cellChanges.push([index, column, value]);}});}for (const id of pendingDeletes) {if (!remoteIds.has(id)) {pendingDeletes.delete(id); // The sheet has our deletion.}}// Physical row indexes: a sort or filter in the grid can't misdirect a value.if (cellChanges.length > 0) {hot.setSourceDataAtCell(cellChanges, undefined, undefined, 'sync');}const removed = rows.filter((row) => {const id = idOf(row);return !remoteIds.has(id) && !pendingAppends.has(id);});if (removed.length > 0) {rows = rows.filter((row) => !removed.includes(row));structural = true;}if (structural) {reindex();hot.updateData(rows, 'sync');reapplyView();}}function enqueueWrite(task: () => Promise<void>) {writeQueue = writeQueue.then(task).catch(handleError);}// Writes a batch of grid edits. Reads the sheet first to find each row's current position// and to catch edits someone else made to the same cell since the grid last synced.async function flushEdits(batch: Map<string, Edit>) {try {await retrying(() => writeEdits(batch));} catch (error) {// Nothing from this batch reached the sheet: put the grid back to what the sheet holds.for (const edit of batch.values()) {settle(edit);const index = indexById.get(edit.id);if (index !== undefined) {hot!.setSourceDataAtCell(index, edit.column, edit.base, 'sync');}}throw error;}}async function writeEdits(batch: Map<string, Edit>) {const { names, body } = parse(await readSheet());checkColumns(names);const remoteById = byId(body);const toWrite: { sheetRow: number; column: number; value: Cell }[] = [];const written: Edit[] = [];// An edit that is settled here leaves the batch, so a retry doesn't handle it twice.for (const [editKey, edit] of batch) {const remote = remoteById.get(edit.id);if (!remote) {settle(edit);batch.delete(editKey);continue; // The row was deleted in the sheet; the next poll removes it here too.}const { sheetRow } = remote;const current = remote.row[edit.column];if (current !== edit.base && current !== edit.value) {// Someone changed this cell in the sheet after the grid last read it. Keep their value.settle(edit);batch.delete(editKey);const index = indexById.get(edit.id);if (index !== undefined) {hot!.setSourceDataAtCell(index, edit.column, current, 'sync');}notify('Edit not saved', `Someone changed "${header[edit.column]}" in the sheet first. Showing their value: ${current}`);continue;}written.push(edit);if (current !== edit.value) {toWrite.push({ sheetRow, column: edit.column, value: edit.value });}}await writeCells(toWrite);const now = Date.now();written.forEach((edit) => {edit.pending.writtenAt = now;});}// Appends rows created in the grid. A row that undo brought back keeps its ID, so it is the same// row again; an empty new row gets a stable ID first.function sendNewRows(created: Cell[][]) {const newRows: Cell[][] = [];const newIds: string[] = [];for (const row of created) {const index = rows.indexOf(row);if (index === -1) {continue; // Removed again before it could be sent.}let id = idOf(row);if (id === '') {id = crypto.randomUUID();hot!.setSourceDataAtCell(index, idColumn, id, 'sync');}pendingAppends.add(id);pendingDeletes.delete(id);newIds.push(id);newRows.push(row.map(normalize));}reindex();if (newRows.length === 0) {return;}enqueueWrite(async () => {try {// Each try reads the sheet first. A 5xx can come back after Google added the rows, so a// retry skips the ones the sheet already has.await retrying(async () => {const { names, body } = parse(await readSheet());checkColumns(names);const present = byId(body);await appendRows(newRows.filter((row) => !present.has(idOf(row))));});} catch (error) {// Every try failed: the rows never reached the sheet, so the next poll removes them here.newIds.forEach((id) => pendingAppends.delete(id));throw error;}});}function attachHooks(grid: Handsontable) {// A user's edit: remember the value the grid had before (the base), batch for 300 ms.grid.addHook('afterChange', (changes: CellChange[] | null, source: ChangeSource) => {if (!changes || OWN_SOURCES.has(source)) {return;}for (const [visualRow, column, oldValue, newValue] of changes) {const row = rows[grid.toPhysicalRow(visualRow)];const id = idOf(row);const value = normalize(newValue);const existing = editBatch.get(key(id, column as number));if (normalize(oldValue) === value) {continue;}const pending: Pending = { value };editBatch.set(key(id, column as number), {id,column: column as number,base: existing?.base ?? normalize(oldValue),value,pending,});pendingCells.set(key(id, column as number), pending);}clearTimeout(editTimer);editTimer = window.setTimeout(() => {const batch = editBatch;editBatch = new Map();enqueueWrite(() => flushEdits(batch));}, EDIT_DEBOUNCE_MS);});// A new row goes to the sheet. Undo and redo create a row first and refill it after this hook,// so for them the row is read once the restore has finished.grid.addHook('afterCreateRow', (visualIndex: number, amount: number, source?: string) => {if (source && OWN_SOURCES.has(source)) {return;}const created = Array.from({ length: amount }, (_, offset) => rows[grid.toPhysicalRow(visualIndex + offset)]);if (source?.startsWith('UndoRedo')) {queueMicrotask(() => sendNewRows(created));} else {sendNewRows(created);}});// Read the IDs before Handsontable removes the rows from the data.grid.addHook('beforeRemoveRow', (_index: number, _amount: number, physicalRows: number[], source?: string) => {if (source && OWN_SOURCES.has(source)) {return;}const ids = physicalRows.map((physical) => idOf(rows[physical])).filter((id) => id !== '');ids.forEach((id) => pendingDeletes.add(id));enqueueWrite(async () => {try {// Each try reads the sheet first, so it deletes the rows where they are now.await retrying(async () => {const { names, body } = parse(await readSheet());checkColumns(names);const remote = byId(body);const sheetRows = ids.flatMap((id) => remote.get(id)?.sheetRow ?? []);await deleteRows(sheetRows);});} catch (error) {// Every try failed. The rows are still in the sheet, so the next poll brings them back// to the grid.ids.forEach((id) => pendingDeletes.delete(id));throw error;}});});grid.addHook('afterRemoveRow', () => reindex());// The sync's own writes stay off the undo stack, so Ctrl+Z can't revert a colleague's change.grid.addHook('beforeUndoStackChange', (_doneActions: unknown[], source?: string) => source !== 'sync');}function handleError(error: unknown) {if (error instanceof SessionExpiredError) {clearTimeout(pollTimer);onStatus(error.message, true);return;}notify('Sync error', (error as Error).message);}// Reads the sheet every POLL_MS while the tab is visible.async function poll() {try {if (!document.hidden) {const readStartedAt = Date.now();applyRemote(await readSheet(), readStartedAt);pollFailing = false;onStatus(`Synced at ${new Date().toLocaleTimeString()}`);}} catch (error) {// A first read that failed, an expired session, or changed columns: stop and say why.if (error instanceof SessionExpiredError || !hot || columnsChanged) {onStatus((error as Error).message, true);return;}// Offline, for example. Say so once, and keep polling until a read works again.onStatus(`Can't reach the sheet. Retrying every ${POLL_MS / 1000} seconds.`);if (!pollFailing) {pollFailing = true;handleError(error);}}pollTimer = window.setTimeout(poll, POLL_MS);}poll();return {// Restarts polling after the user signs in again.resume: () => {clearTimeout(pollTimer);poll();},};}What’s happening:
- The grid polls. Google can notify a server about file changes through Drive’s
files.watch, but the notification goes to a public HTTPS endpoint, which a browser-only app doesn’t have. Sopollreads the sheet everyPOLL_MS, and pauses while the browser tab is hidden. If a read fails, for example while the user is offline, the grid shows one toast and keeps polling. - Rows are matched by ID.
applyRemotecompares each sheet row with the grid row that has the same ID. A row that moved in the sheet, because someone sorted it or inserted a row above it, still updates the right grid row. A sheet row with an emptyidisn’t synced. If a row was copied in Google Sheets with its ID, only the first row with that ID syncs, and the grid warns about the copy.parseskips blank rows but keeps each row’s sheet row number, so a blank row in the middle of the sheet can’t shift a write to the row below it. - Empty cells are normalized on both sides. The Sheets API leaves out empty cells at the end of a row, and an empty grid cell is
null.parsepads every row to the header’s width, andnormalizeturnsnulland missing cells into''. Without this, every untouched empty cell would look like a change. - Changed values go in by physical row.
setSourceDataAtCelltakes physical row indexes, the positions in therowsarray, so a sort or filter in the grid can’t send a value to the wrong row. It takes all of a poll’s changes in one call and renders once. The'sync'source tells theafterChangehook not to send the change back. - Added and removed rows replace the data. When the sheet has rows the grid doesn’t, or the other way round,
updateDataloads the new array. Sorting and filtering don’t re-run on their own when data changes, soreapplyViewre-applies them, and new rows land in sorted position. - An open editor isn’t interrupted. Setting a cell’s source data refreshes the active editor’s value, which would wipe what the user is typing. While an editor is open,
applyRemoteskips the round, and the next poll catches up. - The grid’s own writes aren’t undone by a poll. A read can return before a write reaches the sheet.
pendingCells,pendingAppends, andpendingDeleteshold what the grid sent, andapplyRemoteleaves those cells and rows alone until the sheet has them. Each pending cell records when its write finished. A read that started after that is the sheet’s current state, so if someone changed the cell in the meantime, their value wins instead of the grid holding on to its own. - Edits are batched and checked.
afterChangecollects a user’s edits for 300 ms and remembers each cell’s value before the edit, the base.writeEditsthen reads the sheet once, finds each row’s current position by ID, and compares the sheet’s value with the base. If they differ, someone changed the cell in Google Sheets after the grid last read it: the grid keeps their value, and the Notification plugin says so. The other edits go out in onevalues:batchUpdate. - New rows get an ID in the grid.
afterCreateRowgives each new row acrypto.randomUUID()ID and appends it to the sheet. A row that undo brings back keeps its old ID: undo creates the row before it refills it, sosendNewRowsreads the row in a microtask, once the refill is done.beforeRemoveRowreads the IDs of the rows being removed while they still exist, then deletes those rows from the sheet by their current row numbers. If either write fails, the pending entry is dropped, and the next poll puts the grid back to what the sheet holds. - Writes run in order.
enqueueWritechains every write on one promise, so a new row reaches the sheet before an edit to that row does. - Undo covers the user’s own edits. The sync writes with the
'sync'source, andbeforeUndoStackChangekeeps those writes off the undo stack, so Ctrl+Z can’t revert a colleague’s change.
- The grid polls. Google can notify a server about file changes through Drive’s
Build the grid
Create
src/grid.ts:TypeScript import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import type { ColumnSettings } from 'handsontable/settings';import 'handsontable/styles/handsontable.min.css';import 'handsontable/styles/ht-theme-main.min.css';import type { Cell } from './sheets';registerAllModules();// A column whose filled cells are all numbers gets the numeric cell type.function columnFor(rows: Cell[][], column: number): ColumnSettings {const filled = rows.map((row) => row[column]).filter((value) => value !== '');const numeric = filled.length > 0 && filled.every((value) => typeof value === 'number');return numeric ? { type: 'numeric' } : { type: 'text' };}// The grid uses arrays, not objects keyed by header: a header such as "Unit.price" would// otherwise be read as a nested path.export function createGrid(container: HTMLElement, header: string[], rows: Cell[][], idColumn: number) {return new Handsontable(container, {data: rows,colHeaders: header,// The ID column is read-only, so a paste or Delete over a selected row can't change an ID.columns: header.map((_, column) => (column === idColumn ? { readOnly: true } : columnFor(rows, column))),// The ID column stays in the data, so edits can be matched to sheet rows, but users never see it.hiddenColumns: { columns: [idColumn] },rowHeaders: true,columnSorting: true,filters: true,dropdownMenu: true,contextMenu: ['row_above', 'row_below', 'remove_row', '---------', 'undo', 'redo'],notification: true,height: 520,stretchH: 'all',themeName: 'ht-theme-main',// licenseKey: 'your-license-key', // get a key at https://handsontable.com/pricing/});}What’s happening:
- The data is arrays, not objects. Handsontable reads a column’s
dataproperty with dots as a nested path, so a header such asUnit.pricewould bind to the wrong place. The grid keeps each row as an array, uses the column index as the column, and shows the sheet’s headers throughcolHeaders. - Column types come from the data.
columnFormakes a column numeric when every filled cell in it is a number, and text otherwise. - The ID stays in the data.
hiddenColumnshides theidcolumn from users. The value is still in each row, so edits can be matched to sheet rows. Hidden columns still take part in copy and paste, so the column is alsoreadOnly: pasting rows or pressing Delete on a selected row can’t change an ID. The sync sets new IDs withsetSourceDataAtCell, which writes the data directly.
- The data is arrays, not objects. Handsontable reads a column’s
Run the app
Replace
src/main.ts:TypeScript import { signIn } from './auth';import { requestsLastMinute, setAccessToken } from './sheets';import { startSync } from './sync';import './styles.css';const button = document.querySelector<HTMLButtonElement>('#sign-in')!;const status = document.querySelector('#status')!;const requests = document.querySelector('#requests')!;function showStatus(text: string, isError = false) {status.textContent = text;status.classList.toggle('is-error', isError);if (isError) {button.hidden = false;}}let sync: ReturnType<typeof startSync> | null = null;button.addEventListener('click', async () => {try {setAccessToken(await signIn());button.hidden = true;if (sync) {sync.resume();} else {sync = startSync(document.querySelector('#grid')!, showStatus);}} catch (error) {showStatus((error as Error).message, true);}});setInterval(() => {requests.textContent = String(requestsLastMinute());}, 1000);Add the styles in
src/styles.css:CSS body {margin: 0;font-family: Inter, system-ui, -apple-system, sans-serif;}main {max-width: 1100px;margin: 0 auto;padding: 20px 16px;}h1 {margin: 0 0 8px;font-size: 22px;}.toolbar {display: flex;gap: 20px;align-items: center;font-size: 14px;}.is-error {color: #b3261e;}button {padding: 6px 12px;font: inherit;cursor: pointer;}Replace
index.html. Google Identity Services must load fromaccounts.google.com. Google doesn’t support self-hosting it.<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Two-way sync with Google Sheets</title><script src="https://accounts.google.com/gsi/client" async></script></head><body><main><h1>Two-way sync with Google Sheets</h1><p class="toolbar"><button id="sign-in" type="button">Sign in with Google</button><span id="status">Not connected</span><span>Requests in the last minute: <strong id="requests">0</strong> / 60</span></p><div id="grid"></div></main><script type="module" src="/src/main.ts"></script></body></html>Start the app:
Terminal window npm run devOpen
http://localhost:5173, select Sign in with Google, and allow access. If the app reports a missing tab, rename the tab in Google Sheets or setVITE_SHEET_NAMEto its name, then restartnpm run dev: Vite reads.env.localonly when it starts. The grid loads the sheet. Open the sheet in a second window and try the following:- Edit a cell in the grid. The sheet shows the new value within a second.
- Edit a cell in the sheet. The grid shows it within about 5 seconds.
- Sort the sheet by any column, then edit a cell in the grid. The right row changes.
- Change a cell in the sheet, then change the same cell in the grid before the next poll. The grid shows a toast and the sheet’s value.
- Type
=1+1in a grid cell. The sheet stores the text=1+1, not2. - Add and remove rows in both places.
- Start editing a cell and wait for a poll. What you typed stays.
How it works — complete flow
- The user signs in, and Google returns an access token to the page.
- The grid reads the whole tab, takes the headers from row 1, and loads the rows that have an ID.
- Every 5 seconds, the grid reads the tab again, matches rows by ID, and applies the changed values with one
setSourceDataAtCellcall, or replaces the data withupdateDatawhen rows were added or removed. - The user edits cells. After 300 ms without another edit, the grid reads the tab, drops each edit whose cell someone else changed, and writes the rest with one
values:batchUpdaterequest. - The next poll sees the written values and clears them from the pending list.
Production considerations
- Publish the consent screen. In Testing mode, only the test users you added can sign in. To let anyone sign in, publish the app. The
spreadsheetsscope is sensitive, so Google reviews the app before it shows the consent screen without a warning. An app for one Google Workspace organization can use the Internal user type, which needs no review. - Consider a narrower scope. With
drive.file, the app only gets the files a user created in it or picked with the Google Picker. Users grant access file by file, and becausedrive.fileisn’t a sensitive scope, it doesn’t need Google’s review. - Add every origin. Google only accepts the client ID from the Authorized JavaScript origins you listed. Add your production origin, and each preview origin you use.
- Close the gap between reading and writing. Before each write, the grid reads the sheet to find each row’s current position, then writes by row number. If someone sorts the sheet or inserts a row in the fraction of a second between the two requests, the write can land in the wrong row. To remove that window, tag each row with developer metadata that holds its ID, and write with
values:batchUpdateByDataFilter, which finds rows by their metadata instead of their position. - Keep the sheet’s shape stable. The grid takes its columns from row 1 once. If someone renames, adds, or removes a column, the grid stops syncing, and the status line asks the user to reload the page. Protect row 1 and the
idcolumn in Google Sheets, so only the people who own the layout can change them. - Store dates as text, or convert them. With
RAW, a date typed in the grid is stored as text, and the sheet doesn’t treat it as a date. To keep real dates, convert the value to a Sheets serial number before writing, or write that column withUSER_ENTERED. - Know the limits. The recipe reads the whole tab on every poll. That suits sheets with up to a few thousand rows. A Google Sheet holds at most 10 million cells. For larger data, keep it in a database and use the
dataProviderplugin. - Use a server for push updates. To get changes in seconds instead of polling, run a small server that receives Drive
files.watchnotifications and forwards them to the browser, as the Kafka recipe does with Server-Sent Events.
What you learned
- How to sign a user in with Google Identity Services and call the Sheets API from the browser, with no server.
- Why rows should be matched by an ID column, not by position.
- How to detect a conflicting edit by comparing the sheet’s value with the value the grid had before the edit.
- Why
RAWkeeps typed text from becoming a formula. - How to apply remote changes to Handsontable by physical row with
setSourceDataAtCell, without interrupting an open editor. - How to stay under the Sheets API quota with polling, batched writes, and exponential backoff.
Next steps
- Stream Kafka topics into the grid — push updates from a server with Server-Sent Events.
- Saving data — the hooks and sources behind the write-back.
- Notification — the toasts the grid shows for conflicts and errors.
- Google Sheets API usage limits — the quota and the recommended backoff.
- Google Identity Services token model — how the browser gets an access token.
Google Sheets is a trademark of Google LLC.