DEV Community

Cover image for Stop Asking LLMs to Do Math: Client-Side Functions in Flutter GenUI with bloc_signals

Stop Asking LLMs to Do Math: Client-Side Functions in Flutter GenUI with bloc_signals

Comments spotlight zero-latency computed signals

The Calculator Fallacy in Generative UI

"An LLM is a phenomenal semantic reasoning engine, but it is an awful, slow, and expensive pocket calculator."

If you haven't watched Andrew "Red" Brogdon's walkthrough on the Flutter channel (Client side functions for Flutter GenUI), stop and watch it. In five minutes, Red exposes the fundamental flaw lurking beneath most early Generative UI demos: the round-trip calculation trap.

Suppose an AI agent generates a recipe or shopping cart card for a user. The user drags a slider to scale the recipe from 2 servings to 6 servings, or edits the quantity of an ingredient.

What happens next in naive GenUI systems?

  1. The client captures the input change.
  2. It serializes the state and sends a network payload back to the remote model over Server-Sent Events (SSE) or WebSockets.
  3. The LLM burns hundreds of tokens computing 3.50 * 3, occasionally hallucinates floating-point arithmetic or loses formatting consistency, and responds over a multi-second latency gap.
  4. The client finally repaints the screen.

Making a remote AI call to multiply two numbers on a mobile device equipped with an Apple Silicon or Snapdragon processor capable of billions of operations per second is an architectural anti-pattern.

As Red demonstrated with the Commis kitchen assistant app, the solution is Client-Side Functions: the agent designs the interface and binds expressions, but delegates deterministic execution to local Dart code registered in the client's catalog.

In bloc_signals_genui and bloc_signals_genui_flutter, we have taken this concept to its logical architectural conclusion by pairing Google's official A2UI protocol with synchronous reactive signals.


The Missing Link: Reactivity Without the Async Tax

Running a Dart function locally fixes the latency and token cost of LLM round-trips. But it creates a secondary architectural challenge: how does the UI react when local state changes?

In asynchronous architectures, developers often end up wiring StreamController pipelines, debouncers, or manual setState() callbacks to propagate local math back into generated widgets. Every slider notch pays an event-loop and microtask tax.

Because bloc_signals_genui is built directly on Google's official a2ui_core engine—which natively uses preact_signals—client-side functions are not just callable routines: they are first-class reactive computed() signals.

flowchart TD
    User["User Drags Slider / Edits Field"] -->|"1. Updates Local DataModel (0ms)"| Data["DataModel Signal ('/recipe/servings')"]
    Data -->|"2. Synchronous Dependency Trigger"| Computed["computed(() => calculateCost(...))"]
    Computed -->|"3. Same-Frame Leaf Rebuild (120 FPS)"| Widget["Text Widget ('$14.50')"]

When a user adjusts a quantity, the calculation runs in the exact same frame. There are zero network round-trips, zero microtask hops, and zero UI flickers.


Step-by-Step: Implementing Client-Side Functions

Here is how you implement and wire client-side functions using bloc_signals_genui.

1. Define the Function in Pure Dart

Subclass FunctionImplementation from bloc_signals_genui (re-exported from a2ui_core). Declare the function's name, return type, and typed JSON argument schema using CommonSchemas.dynamicNumber:

import 'package:bloc_signals_genui/bloc_signals_genui.dart';
import 'package:json_schema_builder/json_schema_builder.dart'
    hide ValidationResult;

class CalculateCostFunction extends FunctionImplementation {
  CalculateCostFunction()
      : super(
          name: 'calculateCost',
          returnType: A2uiReturnType.number,
          argumentSchema: Schema.object(
            properties: {
              'unitPrice': CommonSchemas.dynamicNumber,
              'quantity': CommonSchemas.dynamicNumber,
              'discount': CommonSchemas.dynamicNumber,
            },
            required: ['unitPrice', 'quantity'],
          ),
        );

  @override
  Object? execute(
    Map<String, Object?> args,
    DataContext context, [
    CancellationSignal? cancellationSignal,
  ]) {
    final unitPrice = (args['unitPrice'] as num?)?.toDouble() ?? 0.0;
    final quantity = (args['quantity'] as num?)?.toDouble() ?? 0.0;
    final discount = (args['discount'] as num?)?.toDouble() ?? 0.0;

    return (unitPrice * quantity) * (1.0 - discount);
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that execute() receives a DataContext. That means your function can read or scope additional local surface state if needed, completely decoupled from Flutter widgets.


2. Register the Function in Your Catalog

Add your function to StandardCatalog using .copyWith(...) and pass it to your A2uiSurfaceBloc:

final catalog = StandardCatalog().copyWith(
  functions: [
    CalculateCostFunction(),
    ...BasicCatalog.v0_9().functions.values,
  ],
);

final surfaceBloc = A2uiSurfaceBloc(
  catalogs: [catalog],
);
Enter fullscreen mode Exit fullscreen mode

When your app advertises its capabilities or system prompt to the agent, the agent is informed of calculateCost and its required parameters.


3. The Agent Streams the Reactive Binding

When the AI agent streams down an A2UI JSON component, it doesn't hardcode a static string. Instead, it binds the text property of a Text component to the function call, referencing paths in the local DataModel:

{
  "id": "cost_display",
  "component": "Text",
  "text": {
    "call": "calculateCost",
    "args": {
      "unitPrice": 4.50,
      "quantity": { "path": "/cart/items/0/qty" },
      "discount": 0.10
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Zero-Latency Execution Under the Hood

When bloc_signals_genui and bloc_signals_genui_flutter resolve this component, DataContext.resolveSignal() from a2ui_core evaluates the dynamic function call:

// Conceptual resolution inside DataContext.resolveSignal():
ReadonlySignal<Object?> resolveSignal(Object? value) {
  if (value is Map && value.containsKey('call')) {
    final callName = value['call'] as String;
    final rawArgs = value['args'] as Map<String, dynamic>? ?? {};

    // 1. Recursively resolve arguments into signals
    final argSignals = rawArgs.map(
      (key, val) => MapEntry(key, resolveSignal(val)),
    );

    // 2. Wrap catalog execution in a synchronous computed() signal
    return computed(() {
      final evaluatedArgs = {
        for (final entry in argSignals.entries)
          entry.key: entry.value.value,
      };
      return catalog.invoke(callName, evaluatedArgs, this);
    });
  }
  // Literal values or data bindings ({'path': ...}) resolve directly
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Notice what happens:

  1. Every argument pointing to a DataModel path (for example /cart/items/0/qty) resolves to an underlying reactive signal.
  2. The entire function call compiles into a native computed() signal.
  3. Whenever the user alters the quantity in a TextField or Slider, bloc.add(UpdateFormField('/cart/items/0/qty', newValue)) mutates the DataModel synchronously.
  4. The computed() signal marks itself dirty and recalculates immediately in the exact same frame (0ms latency, zero microtasks).
  5. The Flutter widget (NodeResolver / A2uiSurfaceView) performs a surgical leaf rebuild at 120 FPS.

5. Interactive Actions vs. Bidirectional RPC

In addition to dynamic display properties, client-side functions integrate seamlessly with user actions and remote agent coordination. It is crucial to understand the two distinct operational paths:

Path A: User Interaction via SubmitAction

When a user taps an interactive component (such as a Button), the client dispatches a SubmitAction event to A2uiSurfaceBloc. The action can either trigger a local client-side function (for example applying a local coupon code or validating form inputs) or emit an A2uiRendererAction outbound to the remote agent:

{
  "id": "apply_coupon_btn",
  "component": "Button",
  "text": "Apply 20% Discount",
  "action": {
    "functionCall": {
      "call": "applyDiscountCode",
      "args": { "code": "SAVE20" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Path B: Bidirectional RPC (CallRendererFunctionMessage & callAgentFunction)

Generative UI is not a one-way street. A2uiSurfaceBloc provides full bidirectional RPC across the client-agent boundary:

  1. Inbound RPC (CallRendererFunctionMessage): The remote agent can actively invoke registered client-side functions on demand. If the agent needs to compute a verified total using client business rules, read device GPS coordinates, or query local SQLite storage, it sends a CallRendererFunctionMessage. The client executes the catalog function locally and responds with a typed RendererFunctionResponseMessage.
  2. Outbound RPC (callAgentFunction): The renderer can invoke remote tool capabilities on the agent via bloc.callAgentFunction(), resolving asynchronously when the matching AgentFunctionResponseMessage returns.

Architectural Boundaries for Modern GenUI

Client-side functions draw a clean, resilient line across your Generative UI architecture:

Responsibility Handled By Latency & Reliability
Generative Layout & Reasoning Remote LLM (Gemini, Claude, GPT) Async streaming over network (SSE)
Deterministic Business Logic & Math Local Dart Functions (FunctionImplementation) 0ms synchronous execution on-device
Reactivity & State Synchronization Signals Graph (BlocSignal + a2ui_core) Same-frame UI update without microtasks
Hardware & Native Platform APIs Bidirectional RPC (CallRendererFunctionMessage) Local platform channels & secure sandbox

By adopting Andrew Brogdon's client-side functions pattern and grounding it in a synchronous signal engine, you get the best of both worlds: the creative flexibility of Generative UI with the speed, accuracy, and reliability of native Flutter.


Explore bloc_signals_genui and bloc_signals_genui_flutter on pub.dev, and check out the interactive samples at blocsignal.dev.

Top comments (16)

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The round-trip trap is the sharpest framing here — paying hundreds of tokens and seconds of latency for 3.50 x 3 is hard to defend once you see it. Making the function a first-class computed() signal rather than a callback is the part I'd steal: it kills the flicker, not just the network call. Do you see teams resisting this because the agent has to be told about the catalog, or is the catalog the easy part?

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

The catalog is actually the easy part—especially since modern LLMs are already trained extensively on tool calling and JSON schemas. When an agent connects to an A2UI renderer, advertising registered catalog functions and their CommonSchemas is no different than handing a standard function-calling schema to Gemini or Claude. The models pick up the bindings immediately and rarely hallucinate the arguments once they have typed schemas.

Where I see teams hesitate initially isn't the catalog—it is the paradigm shift regarding boundaries:

  1. The "Oracle Fallacy": In early GenUI demos, the tempting illusion is that the LLM should handle everything end-to-end. Treating the model as an all-knowing oracle instead of a semantic layout compiler makes developers reluctant to draw a hard architectural line between creative layout and deterministic computation.
  2. Fear of Async Plumbing: Teams that haven't worked with synchronous signals naturally assume that wiring client-side execution means building complex StreamController pipelines, debouncers, or manual setState() webs across generated widgets. Once they see that the catalog function compiles directly into a native computed() signal—updating the leaf widget in the exact same frame with 0ms latency and zero microtasks—that resistance evaporates into relief.

Telling the agent what functions exist is just schema advertising. The real win is deciding up front that your app's math, device hardware, and business rules belong to the local processor, not the token budget.

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The Oracle Fallacy is the right name for it — demos that let the model do everything look magical until the bill and the latency arrive. And the async-plumbing fear is real only until someone sees the computed() signal update in the same frame; that's the demo that converts skeptics. The boundary decision up front is the actual architecture work — everything else is just wiring.

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

"The boundary decision up front is the actual architecture work — everything else is just wiring."

That summary shows how well the message resonated. When readers adopt your framing ("The Oracle Fallacy") and independently play back the core principle—that the demo converting skeptics is seeing the synchronous computed() signal update in the exact same frame without the async latency tax—that's how architectural ideas take root.

High-signal discussion from start to finish!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That's the best compliment a framing can get — when someone else starts using it back at you. The demo point is exactly right: seeing the computed() signal update in the same frame is what makes the abstraction click for skeptics. Where does the boundary decision get hardest for you — when the catalog itself is dynamic and the agent has to discover functions at runtime?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Dynamic runtime discovery is actually the straightforward mechanical part—we already solve that cleanly with capability handshakes, role-based scoping, or MCP-style tool discovery (for example, unlocking privileged catalog functions only when a user authenticates or navigates into a specific enterprise surface).

Where the boundary decision gets genuinely difficult is semantic grain size:

1. The "Assembly Language" vs. "God Function" Dilemma

  • If your catalog functions are too granular (like multiply(), add(), or formatCurrency()), you force the agent to compose arithmetic ASTs in JSON. You end up accidentally reinventing a fragile Lisp interpreter in JSON, and the agent will eventually botch business-rule order (for example, calculating regional sales tax before versus after applying manufacturer rebates).
  • If your catalog functions are too monolithic (like calculateEntireCheckoutState()), you strip away the agent's generative flexibility. At that point, you aren't doing Generative UI anymore; you're just writing a classic hardcoded wizard with an AI decorator.

Finding that sweet spot—functions that encapsulate a complete, inviolable domain rule (like calculateCartItemCost or validatePostalCode) while still leaving the layout and data-flow topology to the agent—is where real domain modeling happens.

2. Referential Transparency vs. Local Side Effects

The other hard edge is side-effect boundaries. Pure math (price * qty * (1 - discount)) is trivial because it is referentially transparent—it can recalculate fifty times a second with zero consequences. But as soon as a client function touches device hardware (GPS coordinates, biometric auth) or mutates local persistence (reserving local cache slots), you have to strictly quarantine it out of reactive computed() signals and route it strictly through explicit, deliberate user actions (SubmitAction).

If a function has side effects, it cannot live in the reactive rendering graph. That separation is where architecture either holds or falls apart.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The grain-size framing is the part I'll carry forward — 'a complete, inviolable domain rule' is a much better test for a catalog function than 'does it save a round trip.' And the side-effect point quietly corrects my computed() enthusiasm: the signal trick only works for pure functions, and the moment a function touches hardware or persistence it has to leave the reactive graph. Where do you draw that line for functions that only read local state, like validatePostalCode against a cached table — pure enough for computed(), or does any I/O at all push it to SubmitAction?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

The boundary comes down to a simple, physical rule of thumb: is it a synchronous in-memory read, or does it cross an async boundary (disk, network, or IPC)?

1. In-Memory Tables: 100% Safe for computed()

If validatePostalCode queries an in-memory lookup table—like a Set<String>, Trie, or hash map pre-loaded in memory or cached in repository state—it is completely synchronous, deterministic, and executes in sub-microsecond time (O(1)).

It has zero side effects and never leaves the CPU cache. That is completely pure enough for computed(). The user types the fifth digit of a ZIP code, the signal evaluates synchronously, and the validation checkmark or error text repaints in the exact same frame at 120 FPS.

2. Actual I/O (Async Futures): Must Leave the Synchronous Graph

The moment "cached table" means an async call—like querying an on-disk SQLite database, reading file storage, or invoking an async platform channel (Future<bool>)—it must not live directly inside a synchronous computed() signal.

Dart signals propagate synchronously in the UI isolate. If a function returns a Future, you are forced to introduce microtask hops, debouncers, or async state wrappers (AsyncSignal<T>), which reintroduces the very frame-budget jitter and UI flicker you set out to avoid.

The Architectural Compromise: Pre-warm the Cache

In production architectures, we solve this by separating cache population from catalog evaluation:

  • The state container or repository asynchronously pre-warms the cache into memory (or updates it out-of-band via events).
  • The catalog function itself remains a pure, synchronous read against that in-memory state via DataContext.

If a lookup genuinely requires on-demand async I/O that cannot be cached in advance (for example, verifying an address against a live postal API), that is no longer a rendering-time expression—it is an explicit intent. You route it through SubmitAction, let the BLoC handle the loading state, and write the verified result back into the DataModel when the future resolves.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That's the rule of thumb I'll steal — synchronous in-memory read versus async boundary is a test anyone on the team can apply without arguing about purity. The pre-warm compromise is the real answer to my catalog question: the catalog stays a pure read, and the hard part moves to keeping the cache fresh. How do you handle staleness there — TTL on the pre-warmed table, or event-driven refresh when the source changes?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts • • Edited

The classic adage is undefeated here: there are only two hard things in Computer Science: cache invalidation, naming things, and off-by-one errors.

In practice, I strongly favor event-driven refresh over passive TTL, with a pragmatic stale-while-revalidate hybrid when network calls are unavoidable. Here is why:

1. The Trouble with Pure TTL in UI Systems

A passive TTL (for example, expiring an entry after five minutes) is almost always a guessing game:

  • If your timer is too long, the user sees outdated business data right when they are making a decision.
  • If it is too short, you burn network calls and risk cache misses right when the user is interacting with the screen.
  • Worst of all, TTLs expire on wall-clock time rather than user cadence. Having an in-memory cache invalidate mid-drag or mid-keystroke can introduce unexpected layout shifts.

2. Event-Driven Refresh Fits Unidirectional Architecture

Because bloc_signals is already built on unidirectional data flow, event-driven updates are natural:

  • When the external world changes (a WebSocket push, an SSE event, or an explicit server response after an action), an event is dispatched to the repository (like TaxRatesUpdated or InventorySnapshotReceived).
  • The repository updates its in-memory signal.
  • Because the catalog function reads that signal via DataContext, any dependent computed() signal marks itself dirty and recalculates synchronously in that exact same frame. The UI repaints seamlessly without spinners or polling loops.

3. Stale-While-Revalidate for Semi-Static Domain Data

For domain data that changes occasionally (like exchange rates or shipping rate tiers):

  1. Immediate synchronous hit: The catalog function reads the current in-memory cache instantly (0ms), keeping the UI responsive at 120 FPS.
  2. Background verification: If a version tag or soft timestamp indicates the data is aging, a background task fetches the fresh payload out-of-band.
  3. Atomic swap: When the fresh payload arrives, an event writes the new data structure into the state container in one atomic transition, updating the surface without blocking user input.

And for truly static reference data (like standard postal code formats, unit conversions, or ISO country codes)? Version-stamp it with the app release or bundle it in the asset catalog. The fastest, freshest cache is the one that never has to invalidate at all!

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The mid-drag invalidation point is the one that sells it — a TTL expiring mid-keystroke reads as a UI glitch, not a cache bug, so nobody debugs the right layer. Event-driven fits the unidirectional flow so cleanly that the TTL starts to look like a workaround for not having events. Do you version-stamp the catalog itself, or does the event carry the version tag?

Collapse
 
paullovelace profile image
Paul Lovelace •

The Oracle Fallacy is the right name for it — demos that let the model do everything look magical until the bill and the latency arrive. Making the function a first-class computed() signal rather than a callback is the part I'd steal: it kills the flicker, not just the network call. One thing I'm curious about: when the agent invents a call to a function that isn't in the catalog, what does the surface do — fail loud, or quietly render nothing?

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Great question, Paul. It depends on whether the call happens via Inbound RPC or Reactive UI Binding, but in both cases it fails explicitly and loudly rather than silently swallowing the error into a ghost screen.

Here is what happens under the hood:

1. Inbound RPC (CallRendererFunctionMessage)

If the remote agent sends a direct RPC message requesting a function that doesn't exist in the catalog, A2uiSurfaceBloc intercepts it and immediately returns a structured RendererFunctionResponseMessage with a typed error:

{
  "functionCallId": "call-123",
  "error": "Function not found: computeRocketTrajectory"
}
Enter fullscreen mode Exit fullscreen mode

The agent gets immediate feedback that the tool isn't registered, and the client application continues running without a hitch.

2. Reactive UI Bindings ({"call": "unknownFunction"})

When a streamed component binds a dynamic property (like a Text or Card) to an unregistered function:

  • catalog.invoke() throws a typed A2uiCatalogResolutionError: Function not found: <name>.
  • Inside the signals graph, the computed() signal captures this as a SignalEffectException.
  • In debug mode, this flags immediately in your logs and DevTools console, identifying the exact missing function name so you don't spend hours wondering why a field is blank.
  • In the Flutter widget tree (bloc_signals_genui_flutter), component resolvers are wrapped in defensive error boundaries. The individual offending node renders its fallback error state (or standard debug error tile), while the rest of the surface tree continues to mount and render cleanly.

No silent black holes or phantom state!

Collapse
 
aifrontierpost profile image
AI Frontier Post •

Paying hundreds of tokens to compute 3.50 times 3 is the part that should bother people, the phone does it in the same frame at 0ms. The 20 percent coupon button is the flip side though, once that discount runs locally with no server check, a tampered client can apply it as often as it likes.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Spot on, and this touches the first law of client-server security: never trust the client for final transactional authority.

Client-side functions exist for responsive user ergonomics and 120 FPS visual feedback, not for final financial settlement.

It is the exact same invariant web developers have lived with for thirty years:

  • Your client-side JavaScript (or Dart in Flutter) calculates cart totals and renders the discount instantly so the user doesn't wait through a spinner while typing or dragging sliders.
  • But when the user taps "Place Order", your backend API / payment gateway calculates the authoritative price against the database and applies the verified coupon rules before charging the card. Nobody lets a browser POST {"total_to_charge": 0.00} directly to Stripe!

In bloc_signals_genui, this is why we separated Path A (Interactive Actions) and Path B (Bidirectional RPC):

  1. Local expressions like calculateCost handle instant preview arithmetic in the UI signal graph.
  2. When an authoritative action occurs (like applying a restricted promo code or checking out), SubmitAction emits an A2uiRendererAction or invokes callAgentFunction() to validate against the server before committing state.

The phone gives you 0ms interactive rendering; the server keeps the ledger honest.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.