Skip to content

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.

Open in the demo runner

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 id column.
  • 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 Requests or a server error, and a request counter against the quota.

Before you begin

RequirementVersion
Node.js20 or later
handsontable18.1 or later
Viteany current version
A Google accountwith access to Google Cloud console

Create the app:

Terminal window
npm create vite@latest sheets-grid -- --template vanilla-ts
cd sheets-grid
npm install handsontable
  1. Set 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.

    1. In the Google Cloud console, create a project, or pick an existing one.
    2. Go to APIs & Services > Library, find Google Sheets API, and select Enable.
    3. 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.
    4. 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:5173 is a different origin. The token flow needs no redirect URI. Copy the client ID.

    Then prepare the sheet:

    1. Create a Google Sheet. Put the column names in row 1, and name one column id.
    2. Give every row a unique value in the id column. Any text works, for example 1, 2, 3, or a UUID.
    3. Copy the spreadsheet ID from its URL: https://docs.google.com/spreadsheets/d/<spreadsheet-id>/edit.

    Create .env.local in the app folder:

    Terminal window
    VITE_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
    VITE_SPREADSHEET_ID=your-spreadsheet-id
    VITE_SHEET_NAME=Sheet1

    The client ID isn’t a secret: it identifies your app to Google, and Google only accepts it from the origins you listed. Keep .env.local out of version control anyway, so each developer uses their own sheet.

  2. 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_HEADER names the column that identifies rows, and POLL_MS sets 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.

  3. 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. initTokenClient from 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 signIn runs 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 spreadsheets scope covers every sheet the user can open. That is what lets the app open a sheet it didn’t create. The narrower drive.file scope only covers files the app created or the user picked with the Google Picker. See Production considerations.
  4. 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 answers Unable to parse range. readSheet turns that into a message that lists the tabs the spreadsheet does have.
    • One wrapper for every request. sheetsFetch adds the bearer token. A read that gets 429 or a 5xx response 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 throws RetryableError, and retrying runs the whole write task again, read included, so each try finds the rows where they are. A 401 throws SessionExpiredError instead of retrying.
    • Numbers come back as numbers. readSheet asks for UNFORMATTED_VALUE, so a price is 12.5, not "$12.50", and the grid can sort and edit it as a number. dateTimeRenderOption=FORMATTED_STRING returns dates as the text the sheet shows, instead of serial numbers.
    • RAW keeps text as text. writeCells sends valueInputOption: 'RAW', so the sheet stores each value exactly as sent. With USER_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:batchUpdate writes 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:append looks for a table starting at A1 and 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. deleteRows sorts the row numbers from the bottom, so each deletion leaves the rows still to delete where they were.
    • Sheet names are quoted. quotedSheet wraps the tab name in single quotes, so a name with a space or an apostrophe still works in a range.
  5. 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. So poll reads the sheet every POLL_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. applyRemote compares 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 empty id isn’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. parse skips 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. parse pads every row to the header’s width, and normalize turns null and missing cells into ''. Without this, every untouched empty cell would look like a change.
    • Changed values go in by physical row. setSourceDataAtCell takes physical row indexes, the positions in the rows array, 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 the afterChange hook 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, updateData loads the new array. Sorting and filtering don’t re-run on their own when data changes, so reapplyView re-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, applyRemote skips 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, and pendingDeletes hold what the grid sent, and applyRemote leaves 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. afterChange collects a user’s edits for 300 ms and remembers each cell’s value before the edit, the base. writeEdits then 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 one values:batchUpdate.
    • New rows get an ID in the grid. afterCreateRow gives each new row a crypto.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, so sendNewRows reads the row in a microtask, once the refill is done. beforeRemoveRow reads 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. enqueueWrite chains 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, and beforeUndoStackChange keeps those writes off the undo stack, so Ctrl+Z can’t revert a colleague’s change.
  6. 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 data property with dots as a nested path, so a header such as Unit.price would 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 through colHeaders.
    • Column types come from the data. columnFor makes a column numeric when every filled cell in it is a number, and text otherwise.
    • The ID stays in the data. hiddenColumns hides the id column 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 also readOnly: pasting rows or pressing Delete on a selected row can’t change an ID. The sync sets new IDs with setSourceDataAtCell, which writes the data directly.
  7. 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 from accounts.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 dev

    Open 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 set VITE_SHEET_NAME to its name, then restart npm run dev: Vite reads .env.local only when it starts. The grid loads the sheet. Open the sheet in a second window and try the following:

    1. Edit a cell in the grid. The sheet shows the new value within a second.
    2. Edit a cell in the sheet. The grid shows it within about 5 seconds.
    3. Sort the sheet by any column, then edit a cell in the grid. The right row changes.
    4. 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.
    5. Type =1+1 in a grid cell. The sheet stores the text =1+1, not 2.
    6. Add and remove rows in both places.
    7. Start editing a cell and wait for a poll. What you typed stays.

How it works — complete flow

  1. The user signs in, and Google returns an access token to the page.
  2. The grid reads the whole tab, takes the headers from row 1, and loads the rows that have an ID.
  3. Every 5 seconds, the grid reads the tab again, matches rows by ID, and applies the changed values with one setSourceDataAtCell call, or replaces the data with updateData when rows were added or removed.
  4. 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:batchUpdate request.
  5. 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 spreadsheets scope 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 because drive.file isn’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 id column 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 with USER_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 dataProvider plugin.
  • Use a server for push updates. To get changes in seconds instead of polling, run a small server that receives Drive files.watch notifications 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 RAW keeps 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

Google Sheets is a trademark of Google LLC.