Ali Rathore.

June 2026

Treat the model as a cell, not a function call

Why I model each LLM call in my app as a derived spreadsheet cell that recomputes when its inputs change, and where that model breaks

Three weeks in, I deleted the app and opened an empty file.

The version I threw away ran. The user typed something, clicked a button, and an LLM call fired. Another button, another call. State lived in a heap of handlers, each one reaching in and mutating whatever fields it touched. Adding a feature meant tracing which button had last written which field, and by week three the tracing was most of the work. I could not add a step without breaking two I had already shipped.

The rebuild started from two old ideas. Out of the Tar Pit, which says keep your essential state as plain relational data and derive everything else declaratively. And the spreadsheet, where you change one cell and the sheet works out, in the right order, which cells now have to recompute.

So I set one rule, and the whole app is built on it. An LLM call is not an action the user fires. It is a derived cell, and like every derived cell it recomputes when one of its inputs changes.

Start with the state. The essential state is a list of messages, appended, never edited. The current state is fold(handlers, messages): begin from nothing and replay every message through its handler to arrive at now. Nothing else is authoritative. Because the state is only the fold of the messages, undo means dropping the last message and folding again. The same mechanism gives replay, and time-travel to any point you name, at no extra cost. I never wrote an undo system. It fell out of the representation.

On top of that state sits a graph of derived values, sorted topologically. Change an input and the recalc walks the graph in dependency order, recomputing only the cells downstream of what moved. One of those cells calls a model.

That cell is the part to be exact about. The model is a typed data primitive. It reads its inputs from the cells upstream of it and returns a value, and that value carries two things at once: data for the cells downstream, and the rendered UI the user sees. It sits in the sheet, and when the cells it depends on change, the recalc re-runs it the same way it re-runs a sum.

Brainstorming mode is where this stopped feeling like a trick. The model proposes a handful of options. None of them are state. They are a derived view, recomputed from whatever the user has entered so far. The user clicks one, the click appends a message, the message joins the fold, and the next recalc flows from it. Nothing is committed until a click writes it, and the options keep regenerating as the inputs move, because they are a cell and the cell is downstream of the inputs.

essential state, replayedmessages[ ]append only, never editedfold(handlers, msgs)relational statederived cells, recomputed in topological order=sum(rows)cheap, pure, reproducibleLLM cell (typed primitive)in upstream cells, out data and UIfires on input change, 1 to 2s, costs money, not reproducibledownstream cellsUI on screen
An LLM call living in the recalc graph as a derived cell
The model is a cell. Poke an input.
Editing an input recomputes only the cells downstream of it. summary is an LLM cell, so it fires the model; count is plain; theme feeds nothing.
inputs
orders
tone
theme
derived
count = rows
summary = llm(orders, tone)
LLM calls since you started: 0  ·  click an input to edit it

You can point at any value on the screen and walk it backward: this cell, from these inputs, from these messages. The first version could not answer that question about itself.

Here is where the design fights itself. A spreadsheet works because its cells are cheap, pure, and reproducible. The formula =A1+B1 costs nothing, returns the same answer every time, and survives a thousand recomputes an hour without a thought. Topological recalc assumes exactly that: recomputing is free, so recompute whenever an input moves and never worry about the bill.

An LLM cell breaks all three assumptions. A call costs a second or two and real money. It is not pure, so the same inputs can come back as different prose, a different set of options, a different shape on screen. “Recompute the cell whose inputs changed” therefore means, for this one cell, firing a slow nondeterministic call every time anything upstream of it moves. Nudge a field that feeds the brainstorm and the options you were reading rewrite themselves underneath you. The cleaner I make the dependency graph, the more eagerly the recalc wants to re-fire the one cell that cannot afford to be re-fired.

I have it memoizing on input identity now, refusing to recompute unless an input actually changed value, which buys back the worst of the thrashing. It does not buy back the other half. A value sitting in the sheet, the model’s output, is one the sheet itself cannot reproduce.