DEV Community

righ
righ

Posted on

A Spreadsheet Library Where You Can Build =LLM("question") — GridSheet v3 Release Notes

GridSheet v3 is a major update that fundamentally overhauls the foundation of our spreadsheet UI library. This article covers the key features added since v2.2.0 along with the breaking changes in v3.

GridSheet preview

Try the demos: Basic Demo | Async + Spilling Demo | Single HTML Demo (CDN)

GridSheet works not only with React, but also with Vue, Svelte, and Preact. It can even run in a single HTML file via CDN — no build tools required.

1. Async Function Support

First-class async/await support for formula functions (introduced in v2.2.0).

Background: Designed with LLM and External API Integration in Mind

Integration with external APIs — including LLMs — has become commonplace. If spreadsheet cells could call APIs directly and display or aggregate results, the possibilities for data analysis and workflow automation expand dramatically. The async function support in GridSheet was designed with exactly these use cases in mind.

Usage

Simply extend BaseFunctionAsync and make the main() method async.

class FetchWeather extends BaseFunctionAsync {
  example = 'FETCH_WEATHER("Tokyo")';
  description = 'Fetches weather data for the specified city.';
  defs = [{ name: 'city', description: 'City name' }];
  ttlMilliseconds = 60000; // 1 minute cache

  async main(city: string) {
    const resp = await fetch(`https://api.weather.example.com/city=${city}`);
    const data = await resp.json();
    return data.temperature;
  }
}
Enter fullscreen mode Exit fullscreen mode

Per-Cell Cache with Argument Hashing

Async function results are cached per cell. When recalculation runs, the cache key is generated from "function name + argument hash (cyrb53)." If the key matches the previous run, the cached value is returned immediately. If the arguments change, the hash changes, and the function re-executes automatically.

Setting ttlMilliseconds adds an expiration to the cache. Expired caches are discarded on the next evaluation, triggering a fresh API call.

class FetchPrice extends BaseFunctionAsync {
  ttlMilliseconds = 30000; // Expires after 30 seconds → re-fetches
  // ...
}
Enter fullscreen mode Exit fullscreen mode

This ensures the API is only called for cells whose inputs actually changed, minimizing unnecessary network traffic.

In-Flight Sharing

With useInflight (default: true) enabled, when multiple cells simultaneously call the same function with identical arguments, they share a single Promise.

For example, if both A1 and B1 call =FETCH_WEATHER("Tokyo"), normally that would be 2 API requests. With in-flight sharing, only 1 request is made. The result from the first Promise is distributed to all cells that referenced it.

A1: =FETCH_WEATHER("Tokyo")  ─┐
                               ├─ Same args → Share Promise → 1 API call
B1: =FETCH_WEATHER("Tokyo")  ─┘

C1: =FETCH_WEATHER("Osaka")  ─── Different args → Separate API call
Enter fullscreen mode Exit fullscreen mode

Pending Propagation

Cells that depend on async function results (e.g., =SUM(A1:A3) where A1 is async) automatically enter Pending state until their dependencies resolve. After resolution, cascade recalculation runs along the dependency graph.

A1: =FETCH_WEATHER("Tokyo")     ← Pending → 25
A2: =FETCH_WEATHER("Osaka")     ← Pending → 28
A3: =AVERAGE(A1:A2)             ← Pending until A1,A2 resolve → 26.5
Enter fullscreen mode Exit fullscreen mode

Errors are displayed as #ASYNC!.

2. Spilling Support

Spilling is a mechanism where a single formula populates multiple cells. When main() returns a Spilling object (a 2D array wrapper), values automatically flow into adjacent cells from the origin cell.

Synergy with Async Functions

Spilling pairs exceptionally well with async functions, enabling a single API call to populate multiple columns. This is a huge advantage for functions that involve network communication.

Here's an example that fetches repository info from the GitHub API and expands Stars, Forks, Issues, Size, and Subscribers in one shot:

class GhRepoFunction extends BaseFunctionAsync {
  example = 'GH_REPO("facebook/react")';
  description = 'Fetches GitHub repo info and spills across multiple columns.';
  defs = [{
    name: 'repo',
    description: 'Repository in "owner/repo" format',
    acceptedTypes: ['string'],
  }];
  ttlMilliseconds = 60 * 1000;

  async main(repo: string) {
    const resp = await fetch(`https://api.github.com/repos/${repo}`);
    const data = await resp.json();
    // Returns a 1×5 Spilling → auto-expands to columns B–F
    return new Spilling([[
      data.stargazers_count,
      data.forks_count,
      data.open_issues,
      data.size,
      data.subscribers_count,
    ]]);
  }
}
Enter fullscreen mode Exit fullscreen mode

On the spreadsheet, it looks like this:

A (Repository) B (Stars) C (Forks) D (Issues) E (Size) F (Subscribers)
1 facebook/react =GH_REPO(A1) → spills right
2 vuejs/core =GH_REPO(A2)
3 sveltejs/svelte =GH_REPO(A3)
4 Total =SUM(B1:B3) =SUM(C1:C3) =SUM(D1:D3) ... ...

18 cells of data from just 3 API calls. Without Spilling, each cell would need its own API call — instead, one request per repository is all it takes.

Synchronous functions can also use Spilling by setting autoSpilling = true, used in matrix functions like TRANSPOSE, MMULT, and SEQUENCE.

3. Enhanced BaseFunction

v3 significantly enhances the BaseFunction class by introducing automatic validation generated from type definitions (defs). This allows inherited functions to focus purely on their logic, with minimal boilerplate.

Real Example: The COS Function

Here's the actual COS function implementation:

class CosFunction extends BaseFunction {
  example = 'COS(PI())';
  description = 'Returns the cos of the angle specified in radians.';
  defs: FunctionArgumentDefinition[] = [
    {
      name: 'angle',
      description: 'An angle in radians, at which you want the cos.',
      acceptedTypes: ['number'],
    },
  ];
  category: FunctionCategory = 'math';

  protected main(angle: number) {
    return Math.cos(angle);
  }
}
Enter fullscreen mode Exit fullscreen mode

That's it. Checking whether the argument is a number, validating argument count, extracting scalars from cell references (=COS(A1)) — BaseFunction handles all of this automatically based on the defs definition. Developers just write pure logic in main().

v2 vs v3 Comparison

// v2: Had to write validation manually
class MyFunc extends BaseFunction {
  helpTexts = ['Returns the sum of two numbers.', 'a: first number', 'b: second number'];

  protected main(a: number, b: number) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new FormulaError('#VALUE!', 'Please provide numbers');
    }
    return a + b;
  }
}

// v3: Just define defs — argument count and type checking are automatic
class MyFunc extends BaseFunction {
  example = 'MY_FUNC(1, 2)';
  description = 'Returns the sum of two numbers.';
  category: FunctionCategory = 'math';
  defs: FunctionArgumentDefinition[] = [
    { name: 'a', description: 'First number', acceptedTypes: ['number'] },
    { name: 'b', description: 'Second number', acceptedTypes: ['number'] },
  ];

  protected main(a: number, b: number) {
    return a + b;  // Pre-validated values are passed in
  }
}
Enter fullscreen mode Exit fullscreen mode

Automatic Validation Details

The following is automatically handled based on defs:

Feature Definition Automatic Behavior
Argument count check defs length + optional / variadic #N/A on too few or too many
Type checking acceptedTypes: ['number', 'string', ...] #VALUE! on mismatch
Variadic arguments variadic: true Accept repeating parameters
Optional arguments optional: true Allow omission
1x1 matrix extraction Default behavior Auto-unwrap single cell references to scalar
Matrix passthrough takesMatrix: true Suppress broadcasting, pass matrix as-is
Percentage string conversion Default behavior "50%"0.5

When built-in validation isn't enough, override validate(args) with custom coercion logic:

protected validate(args: any[]): any[] {
  const matrix = this.extractNumberMatrix(args[0], 'matrix');
  this.requireSquare(matrix, 'MY_MATRIX_FUNC');
  return [matrix];
}
Enter fullscreen mode Exit fullscreen mode

The example, description, and category fields are displayed in the formula autocomplete UI, helping users discover and understand functions.

4. Functions Package Separation

In v2, all standard formula functions (SUM, VLOOKUP, etc.) were bundled in @gridsheet/core. As functions grew, the core package became bloated, and unused functions were included in the bundle.

v3 extracts standard functions into a separate @gridsheet/functions package with category-based subpath exports.

@gridsheet/functions
├── ./math        (SUM, PRODUCT, SQRT, MOD, ROUND, SUMIF, COUNTIF, ...)  45+ functions
├── ./statistics  (AVERAGE, STDEV, VAR, PERCENTILE, CORREL, ...)         20+ functions
├── ./text        (UPPER, LOWER, TRIM, CONCATENATE, FIND, REPLACE, ...)  20+ functions
├── ./lookup      (VLOOKUP, HLOOKUP, INDEX, MATCH, ...)                  10+ functions
├── ./time        (DATE, TIME, YEAR, MONTH, DAY, HOUR, ...)              15+ functions
├── ./logical     (IF, IFS, XOR, IFNA, ...)                              5+ functions
└── ./information (ISTEXT, ISNUMBER, ISDATE, ISERROR, ...)               Exclude entirely if unneeded
Enter fullscreen mode Exit fullscreen mode

Tree-Shaking Support

Import only the categories you need:

// Only math functions
import { mathFunctions } from '@gridsheet/functions/math';

// All functions
import { allFunctions } from '@gridsheet/functions';

// Recommended: register all categories at once with useSpellbook
import { useSpellbook } from '@gridsheet/react-core/spellbook';
const book = useSpellbook({ additionalFunctions: { my_func: MyFunc } });
// → math, statistics, text, lookup, time, logical, information — all available
Enter fullscreen mode Exit fullscreen mode

The core package now focuses on the formula engine, sheet management, and Policy system, preventing bloat from function additions.

5. Policy Unification

In v2, Renderer (cell rendering), Parser (value conversion), Labeler (header labels), and Policy (input constraints) existed as separate classes. In practice, these were tightly coupled — for example, "a cell that displays percentages should also parse percentages on input and convert to decimals during formula evaluation" required configuration across multiple classes.

v3 merges all of these into a single Policy class.

// v2: Configure 3 separate classes
const myRenderer = new Renderer({ mixins: [ThousandSeparatorRendererMixin] });
const myPolicy = new Policy({ mixins: [DropdownPolicyMixin] });

// v3: One Policy covers everything
const myPolicy = new Policy({
  mixins: [ThousandSeparatorPolicyMixin, DropdownPolicyMixin],
});
Enter fullscreen mode Exit fullscreen mode

PolicyMixinType covers rendering, serialization, deserialization, scalar conversion, and selection helpers all in one place.

Hook Purpose
render / renderCallback Cell rendering (full custom / wrapper)
serialize / deserialize Clipboard and initial value conversion
toScalar Scalar conversion during formula evaluation
getSelectOptions Dropdown choices
renderRowHeaderLabel / renderColHeaderLabel Header label customization

By consolidating into one class, "a cell's behavior" can be defined and reused as a single Policy object. Mixin composition remains supported, allowing you to combine existing behaviors into new Policies.

6. Function Guide Display

When entering formulas, function names and argument guides now display in real time.

  • During autocomplete: Selecting a function candidate shows its category, usage example (example), description, and each argument's name/type/description
  • While typing: Moving the cursor within a function's parentheses highlights the current argument with its type and description. Variadic arguments (variadic) are supported with proper parameter cycling

These guide details are auto-generated from BaseFunction's example, description, defs[].description, and defs[].acceptedTypes. Simply defining a function provides user-facing documentation with zero extra effort.

7. Canvas Overlay for Cell State Rendering

v3 migrates cell selection states, copy/cut ranges, autofill, and search highlighting to a Canvas-based Overlay.

In v2, DOM elements were used for these state representations, but selecting many cells caused DOM node proliferation and high rendering costs. In v3, the CellStateOverlay component draws all states on a single <canvas>:

  • Selection range: Blue border with semi-transparent fill
  • Active cell: 2px bold border
  • Copy/Cut: Dashed border
  • Formula references: Color palette highlighting for reference ranges
  • Search matches: Green background highlight
  • Header highlight: Emphasis on selected column/row headers

Draw scheduling via requestAnimationFrame and synchronous drawing on scroll deliver smooth interaction even with massive datasets (1 million cell demo).

Column menu

8. Filter and Sort

Column header menus now offer filter and sort capabilities (introduced in v2.2.0).

Filter

Combine multiple conditions per column from 10 types: eq, ne, gt, gte, lt, lte, blank, nonblank, includes, excludes — with AND/OR logic switching. Comparisons are type-aware for numbers, dates, times, and strings.

Set filterFixed: true on specific rows to keep them always visible regardless of filter conditions — useful for header rows or totals.

Sort

Ascending (A→Z) and descending (Z→A) sorting with type-aware comparison. Null/undefined values are placed at the end. Rows with sortFixed: true are excluded from sorting and maintain their original position.

Async Function Integration

Both filter and sort are designed with async functions in mind. When Pending cells exist, operations wait until all async computations complete, then execute based on resolved values. A "Filtering…" / "Sorting…" loading indicator with a cancel button is shown during the wait.

9. Renames and API Cleanup

v3 unifies naming for clarity and consistency. The biggest change is the rename from HubBook and TableSheet.

Book (formerly Hub) is the object passed to GridSheet's props. Passing the same book to multiple <GridSheet> components shares values across sheets, enabling cross-sheet references (e.g., =Sheet1!A1 + Sheet2!B2). In v2 this was called Hub, but "book" and "sheet" are far more natural for the spreadsheet mental model. v3 unifies this terminology across the entire codebase.

import { useSpellbook } from '@gridsheet/react-core/spellbook';

// useSpellbook = useBook + all standard functions preloaded
const book = useSpellbook({
  additionalFunctions: { my_func: MyFunc },  // Add custom functions
  policies: { percent: new Policy({ mixins: [PercentagePolicyMixin] }) },
});

// Pass the same book to multiple GridSheets for cross-sheet references
<GridSheet book={book} sheetName="Sheet1" initialCells={...} />
<GridSheet book={book} sheetName="Sheet2" initialCells={...} />
Enter fullscreen mode Exit fullscreen mode

Related APIs have been consistently renamed:

v2 v3 Notes
Hub Book Multi-sheet management unit
Table / UserTable Sheet / UserSheet Individual sheet data
Connector SheetRef / StoreRef Clear separation of sheet data vs UI state
cell.system.changedAt sheet.__raw__.getSystem(point).changedTime Unified naming with sheet-level changedTime
helpTexts[] description + defs[].description Structured function documentation

Connector mixed sheet data access and UI state manipulation in a single object. v3 splits this into SheetRef (sheet data read/write) and StoreRef (UI control like cursor and selection state), clarifying responsibilities.

// v2
const connector = useConnector();
const table = connector.current.tableManager.instance;

// v3
const sheetRef = useSheetRef();
const storeRef = useStoreRef();
const sheet = sheetRef.current?.sheet;
Enter fullscreen mode Exit fullscreen mode

Summary

Change Purpose
Async function support First-class support for LLM and external API integration
Spilling Populate multiple cells from a single call; powerful combo with async
Enhanced BaseFunction Auto-validation from type definitions; minimal code for functions
Functions package split Prevent core bloat; tree-shaking support
Policy unification Rendering, conversion, and constraints in one class
Function guide Auto-generated from defs; real-time argument highlighting
Canvas Overlay Cell state rendering on Canvas; smooth with massive datasets
Filter and Sort Type-aware conditional filter/sort; waits for async completion
Renames and API cleanup Clarified responsibilities and consistent naming

v3 is a release that lays the foundation for "connecting spreadsheets to the outside world." The combination of async functions and Spilling enables workflows where data fetched from APIs is aggregated and analyzed on a spreadsheet in real time — with minimal code.

Development Retrospective

A significant portion of v3 was implemented with Claude's assistance. Routine tasks — batch renames, test scaffolding, function generation — were incredibly fast, condensing days of work into hours.

However, design remains a challenge for AI. Reviewing proposed designs and iterating on corrections consumed a lot of time. "Tell it how to build something and it delivers accurately, but the judgment of what to build and how to architect it still falls on the human side."

No exaggeration — March was almost entirely spent on this work. Development velocity is definitely up with AI collaboration, but has it gotten easier? Honestly, "the more AI can build quickly, the more you want to do, so it ends up being just as intense."

That said, Claude (Sonnet) delivers remarkably high-quality code for routine implementation work. When the architectural direction is set, a substantial portion of the implementation can be delegated.

One caveat: accepting AI-generated code without review builds a Tower of Babel. AI is good at short-term goal achievement — "make this function work," "make this test pass" — but it still sometimes writes ad-hoc code, and maintaining long-term coherence across the codebase is not its strength. For now, it's essential for humans to own the architecture and continuously course-correct through review.

I'm hopeful that the next round of feature development will allow delegating even more — from design through implementation.

https://github.com/walkframe/gridsheet

Top comments (0)