Why Our Blazor App Still Uses TypeScript

By Jeffrey Needles

July 14, 2026
Engineering
Blazor
Templates
Typescript

Why Our Blazor App Still Uses TypeScript

The HCTI template editor is a block-based design tool running as a Blazor WebAssembly application. Blazor lets us bring a large part of our existing C# stack into the browser. It does not make the JavaScript ecosystem disappear, and I do not think trying to pretend otherwise would have improved the editor.

There are mature browser libraries for direct manipulation, rich text and code editing. The DOM also knows things our C# model cannot know on its own, such as an element's current measured bounds or whether a font has actually finished loading. We wanted to use those capabilities without building a second application state in TypeScript.

We ended up with one rule: Blazor owns durable editor state, while TypeScript owns work that is inherently browser-facing. We pass complete results between them instead of crossing the boundary for every small step. This post uses dragging and reparenting as the detailed example, then looks briefly at the same pattern in our geometry, font, TipTap and Monaco integrations.

The architecture overview maps the rest of the editor, and the change tracking post follows how completed TypeScript interactions become undoable model edits.

Deciding Who Owns What

We did not try to minimize JavaScript interop calls as an end in itself. We tried to avoid splitting ownership of the same state between two runtimes.

Blazor and .NET TypeScript and the browser
Template models and property values Pointer sessions and temporary transforms
Selection and editor commands DOM measurement and observers
Validation and panel rules Font loading state
Change tracking and undo history TipTap and Monaco instances
Saving and API communication Browser APIs such as the EyeDropper

TypeScript can report a fact or suggest an outcome. C# decides whether that outcome is valid and commits it to the model. In the other direction, C# can tell a JavaScript library what to display without asking that library to become the source of truth.

Interactivity as the Stress Test

When I say interactivity here, I do not mean every button or input in the editor. Property controls, selection state and most of the surrounding application are normal Blazor components. This layer is for direct manipulation on the canvas:

  • Dragging one or more blocks
  • Resizing from handles
  • Rotating a block
  • Alignment guides and grid snapping
  • Panel hover states, insertion previews and drop targets
  • Moving blocks between the canvas and panels

Our first iteration of that layer was pure C#. It worked well for basic placement, dragging, resizing and rotation. Then came overflowing handles, overlapping drag targets, scroll, zoom, window resizing and panels. Reparenting worked, but each new interaction exposed another edge case. Alignment guides and grid snapping required more measurements and rerenders, and we still needed plenty of JavaScript interop to read positions from the DOM.

We eventually moved the high-frequency work to TypeScript, using interact.js. The C# model and Blazor-rendered blocks remain authoritative. TypeScript reads interaction flags and block IDs from the DOM, then keeps a small in-memory session while the pointer is active. A shortened move session looks like this:

// Shortened for readability.

type MoveInteractionSession = {
  blockId: number;
  startFrame: InteractionRuntimeRect;
  frame: InteractionRuntimeRect;
  sourceParentPanelId: number;
  target: MoveSessionTarget;
};

type MoveSessionTarget =
  | { kind: "none" }
  | { kind: "reparent_canvas" }
  | { kind: "reparent_free"; panelId: number }
  | { kind: "reparent_flex"; panelId: number; targetFlowOrder: number }
  | { kind: "reparent_grid"; panelId: number; targetFlowOrder: number };

That session is a scratchpad, not another template model. It remembers where the block started, its current visual frame and the drop target under consideration. During a drag, TypeScript may update inline transforms, selection chrome, alignment guides and panel insertion previews without sending every pointer movement through the JS interop.

Registering .NET Callbacks

For anyone who has not used Blazor interop before, the callback setup is less involved than it may sound. C# wraps an object in DotNetObjectReference, passes it to a JavaScript module and exposes instance methods with [JSInvokable].

// Shortened for readability.

interactionRuntimeCallbackBridge ??=
    new TemplateEditorInteractionRuntimeCallbackBridge(this);

hostHandle = await InteractionRuntimeInteropService.AttachHostAsync(
    EditorShellRef,
    interactionRuntimeCallbackBridge.AsObjectReference(),
    DefaultInteractionRuntimeOptions);

// Inside the interop service:
var module = await interopModuleService.Interactions();
return await module.InvokeAsync<int>(
    "startInteractionRuntime",
    hostElement,
    callbacks,
    options);

JavaScript receives an object with invokeMethodAsync(...). Calling a .NET instance method is then just:

await callbacks.invokeMethodAsync("HandleInteractionCommit", payload);

Blazor serializes the payload into the C# parameter type. The bridge forwards it to EditorHost, keeping the JavaScript-facing method names and object-reference lifetime separate from the component's editing logic. The object reference is retained for as long as the runtime is attached and disposed when the editor detaches. That last part matters because JavaScript otherwise retains a handle to the managed object.

Because this is Blazor WebAssembly, the call stays inside the browser. It does not make a network round trip or travel through a Blazor Server circuit. Values still have to cross the JavaScript/.NET boundary, and callbacks still enter Blazor's renderer context, so sending every pointer movement through it would add work without buying us anything.

One Interaction, Start to Finish

Not every pointer down becomes an interaction. It might be an ordinary click to select a block, a click inside editable content or a pointer on a disabled or locked target. interact.js first identifies a possible action, then the runtime checks the originating element, mouse button, handle and block flags. It only creates a session once the pointer movement qualifies as a drag, resize or rotation.

Phase TypeScript runtime Blazor/.NET
Pointer down Treats the pointer as a possible interaction. interact.js checks selectors and handles, while the interaction setup rejects locked blocks, disabled actions, non-primary buttons and events originating from nested blocks. No session exists yet. Normal click and selection behavior remains available because no model mutation or interaction commit has happened.
First qualifying movement Chooses the action, reads the block id, parent id, flags and starting frame from the DOM, then creates one ActiveInteractionSession for the element. Blissfully unaware that anything is happening.
Further movement Retrieves the existing session, updates its current frame, applies snapping, measures possible drop targets and writes temporary visual state to the DOM. It does not rebuild the session for each event. For a few specific cases, such as grids, the TypeScript runtime may query .NET about whether a potential drop target is valid.
Pointer up Resolves the final frame and drop target, then calls HandleInteractionCommit with a typed move, resize, rotate or reparent payload. The callback returns to Blazor's renderer context and dispatches the payload to the appropriate C# handler.
Commit and cleanup Clears the temporary frame and interaction previews after handing the result back. The handler applies the real updates through the change tracker, usually as one transaction and one undo step. Generated CSS and normal rendering take over again.

TypeScript owns an interaction session, not the block state. Upon release of the mouse, it calls into .NET code, awaits a result and cleans up any changes it made to DOM elements along the way. If a pointer is canceled, the temporary state can be thrown away. If it completes, .NET receives one description of the result and commits it through the same model rules as any other edit.

Reparenting a Block

Reparenting shows why this division is useful. During the drag, TypeScript can inspect the DOM, find panel drop targets, draw insertion markers and calculate the final frame. It does not get to rewrite the template hierarchy.

When the pointer is released over a flex panel, TypeScript sends a small payload:

void emitCommit(state, {
  kind: "reparent_flex",
  sourceBlockId: session.blockId,
  targetParentPanelId: target.panelId,
  targetFlowOrder: target.targetFlowOrder > 0
    ? target.targetFlowOrder
    : -1,
  frame: resolveCanvasCommitFrame()
});

The kind field selects a concrete C# record. Move, resize, rotate and the different panel types all enter through one callback without becoming an untyped dictionary after they cross into .NET:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(InteractionRuntimeMoveCommitEvent), "move")]
[JsonDerivedType(typeof(InteractionRuntimeReparentFlexCommitEvent), "reparent_flex")]
[JsonDerivedType(typeof(InteractionRuntimeReparentGridCommitEvent), "reparent_grid")]
public abstract record InteractionRuntimeCommitEvent(
    uint SourceBlockId,
    InteractionRuntimeRect? Frame);

public sealed record InteractionRuntimeReparentFlexCommitEvent(
    uint SourceBlockId,
    InteractionRuntimeRect? Frame,
    uint TargetParentPanelId,
    int TargetFlowOrder)
    : InteractionRuntimeCommitEvent(SourceBlockId, Frame);

Tip

Published Blazor WASM applications are trimmed, but JavaScript-created payloads and [JSInvokable] methods may not have an obvious path through the normal C# call graph. We preserve them explicitly with DynamicDependency; otherwise an interop path can work in development and fail only after publishing. The production deep dive shows the complete trim-root pattern.

EditorHost switches on that concrete record and handles the real model update inside a tracked transaction:

public ValueTask OnCommitAsync(InteractionRuntimeCommitEvent commitEvent) =>
    new(InvokeAsync(async () =>
    {
        switch (commitEvent)
        {
            case InteractionRuntimeMoveCommitEvent move:
                HandleBlockMovedAsync(move);
                break;

            case InteractionRuntimeReparentFlexCommitEvent flex:
                using (ChangeTracker.BeginTransaction())
                {
                    HandleReparentFlex(flex);
                }
                await InvokeAsync(StateHasChanged);
                break;
        }
    }));

private bool HandleReparentFlex(
    InteractionRuntimeReparentFlexCommitEvent commit)
{
    var block = FindBlock(commit.SourceBlockId);
    var panel = FindBlock(commit.TargetParentPanelId) as Tracking_PanelBlockData;

    if (block is null || panel is null || !panel.IsFlexPanel())
    {
        return false;
    }

    if (block is Tracking_PanelBlockData &&
        ViewContext.Graph.CreatesParentCycle(block, panel.Id))
    {
        return false;
    }

    var changed = false;
    if (block.ParentPanelId != panel.Id)
    {
        block.UpdateParentPanelId(ChangeTracker, panel.Id);
        block.FlexItem.UpdateFlowOrder(
            ChangeTracker,
            AllocateNextFlexFlowOrder(panel.Id, block.Id));
        changed = true;
    }

    if (commit.TargetFlowOrder > 0)
    {
        changed |= ReorderBlockForParent(
            block,
            panel.Id,
            commit.TargetFlowOrder);
    }

    return changed;
}

The real handler also covers multi-selection, panels becoming full or no longer full and moves within the same parent. Even in the shortened version, TypeScript only reports where the block was dropped. C# confirms that the target is actually a flex panel, prevents parent cycles, updates the typed model and records the operation for undo.

Other Integration Points

Dragging is the busiest crossing between the two runtimes, but it's not the only one. The same ownership rule shows up elsewhere in the editor:

Integration TypeScript responsibility Blazor/.NET responsibility
Block geometry Uses ResizeObserver, MutationObserver and DOM measurements, then reports changed frames. Caches measurements by block ID and combines them with typed layout rules.
Fonts Waits on document.fonts.load(...) so measurements do not race an unloaded font. Tracks fonts used by the template and produces the font-face CSS that needs to load.
TipTap Owns the ProseMirror editor instance, browser selection and rich-text commands. Stores the typed TipTap document, supplies defaults and turns selection changes into editor state.
Monaco Owns the code editor instance, focus and layout. Supplies the document value and language, receives draft changes and decides when blur should become a commit.

Geometry uses the same callback mechanism as interactions. C# starts an observer with a DotNetObjectReference<BlockGeometryService>, and TypeScript batches changed measurements back into [JSInvokable] HandleBlockGeometryMeasurementsChanged(...). We do not repeatedly ask the DOM for every block from C#. The observer reports changes and the service keeps the latest useful measurement.

TipTap and Monaco follow a similar pattern but keep their own JavaScript objects. C# passes an element reference and initial state into the module, then registers callbacks for selection or document changes. For TipTap, the durable value is still our typed TipTapRichDocument. For Monaco, the component still decides whether a draft update or blur should enter the editor's tracked state.

These integrations do not all need the same abstraction. They do share the same answer to the ownership question: use TypeScript for the library or browser behavior it is good at, then pass useful results back to the C# application instead of slowly recreating the application on both sides.

Tip

AI was particularly useful in this part of the project. We were integrating an established library with real documentation, examples and known browser behavior, so it could help trace an edge case through our adapter instead of inventing a new interaction system from scratch. That made bugs involving scroll, zoom and nested drop targets much quicker to reason about.

Conclusion

Most of this editor probably could have been written entirely in C# and Blazor. Our first interaction implementation came pretty close. But being possible in C# does not automatically make it the best place for the work.

Blazor requiring some JavaScript or TypeScript is not a failure of the framework, and it does not turn the application into an awkward hybrid by default. Problems start when both runtimes believe they own the same state. If that ownership is clear, the interop itself can stay small and unsurprising.

Four rules have held up for us:

  • Keep durable application state, validation and change history in Blazor.
  • Let TypeScript own temporary browser state and libraries built around the DOM.
  • Send typed commands and completed results across the boundary, not every intermediate event.
  • Treat callback lifetime, trimming and release builds as part of the integration from the beginning.

The editor still has one authoritative, strongly typed model shared with our servers. It also gets interact.js, TipTap, Monaco, browser observers and font APIs without rebuilding weaker versions of them for the sake of language purity. Using both runtimes has not required us to lower our standards for either one; it has required us to be explicit about the handoff.

Blazor is the application. TypeScript helps it be a better browser application. That has been a much more useful way to think about the relationship.

Template Editor Engineering Series

Post Description
Building a Visual Template Editor in Blazor WASM A high-level tour of the decisions, architecture, and shared systems behind the editor.
Building Consistent Blazor UX with Source Generators Turning typed C# models into consistent property controls, previews, and rendered blocks with source generators.
Undo, Redo, and Live Preview - No Diffs Needed Compact property addresses, generated tracking facades, transactions, undo/redo, and preview state.
Why Our Blazor App Still Uses TypeScript A practical split between Blazor-owned application state and TypeScript-owned browser behavior.
Beyond Text Replacement: Building Typed Template Variables Keeping Handlebars for content while extending variables to colors, sizes, images, and other typed properties.
Building a System to Generate Complex CSS Generating deterministic CSS for nested blocks, panels, previews, and both browser and server renderers.
Simplifying Complexity: MemoryPack to the Rescue MemoryPack storage, read-time schema repair, and typed MagicOnion communication across browser and server.
Productionizing Our Blazor WebAssembly Editor Runtime performance, trimming, loading experience, and browser-backed testing for a production Blazor WASM editor.

Get new posts in your inbox

Occasional HTML/CSS to Image updates and posts. No marketing sequences.

Unsubscribe whenever you like.

Have a question?

We'd love to hear about what you're building.

support@htmlcsstoimage.com
Get Started

You'll be up and running in 5 minutes.

Grab an API Key