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.
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;
}
}
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
// ...
}
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
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
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,
]]);
}
}
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);
}
}
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
}
}
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];
}
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
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
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],
});
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).
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 Hub → Book and Table → Sheet.
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={...} />
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;
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.


Top comments (0)