Blazor Fusion (Stl.Fusion)
Overview
Stl.Fusion is a library that brings computed observables and real-time state synchronization to .NET applications, with first-class support for Blazor. It introduces [ComputeMethod] -- methods whose outputs are cached and automatically invalidated when their dependencies change. When a compute method's result changes, all Blazor components consuming that result re-render automatically. Fusion handles server-to-client replication transparently, making Blazor Server and Blazor WebAssembly apps display real-time data without manual SignalR hubs, polling, or state management boilerplate.
Fusion's core abstraction is IComputed<T>, a versioned, cached container for a method's return value. Computed instances form a dependency graph -- when an upstream computed value is invalidated, all downstream dependents are also invalidated and lazily recomputed on next access.
Installation
dotnet add package Stl.Fusion
dotnet add package Stl.Fusion.Blazor
dotnet add package Stl.Fusion.Server # For Blazor Server or API hosting
Defining Compute Services
Compute services are interfaces with [ComputeMethod] attributes. Implementations must be registered as singletons because Fusion caches results per-instance.
using Stl.Fusion;
namespace MyApp.Services;
public interface IProductService : IComputeService
{
[ComputeMethod]
Task<List<Product>> GetAllAsync(CancellationToken cancellationToken = default);
[ComputeMethod]
Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
[ComputeMethod]
Task<int> GetCountAsync(CancellationToken cancellationToken = default);
Task AddAsync(Product product, CancellationToken cancellationToken = default);
Task UpdateAsync(Product product, CancellationToken cancellationToken = default);
}
public record Product(int Id, string Name, decimal Price, int StockCount);
using Stl.Fusion;
namespace MyApp.Services;
public class ProductService : IProductService
{
private readonly List<Product> _products = new();
private int _nextId = 1;
[ComputeMethod]
public virtual async Task<List<Product>> GetAllAsync(
CancellationToken cancellationToken = default)
{
return _products.ToList();
}
[ComputeMethod]
public virtual async Task<Product?> GetByIdAsync(
int id, CancellationToken cancellationToken = default)
{
return _products.FirstOrDefault(p => p.Id == id);
}
[ComputeMethod]
public virtual async Task<int> GetCountAsync(
CancellationToken cancellationToken = default)
{
return _products.Count;
}
public virtual async Task AddAsync(
Product product, CancellationToken cancellationToken = default)
{
var newProduct = product with { Id = _nextId++ };
_products.Add(newProduct);
// Invalidate computed values that depend on the product list
using (Computed.Invalidate())
{
_ = GetAllAsync(cancellationToken);
_ = GetCountAsync(cancellationToken);
}
}
public virtual async Task UpdateAsync(
Product product, CancellationToken cancellationToken = default)
{
var index = _products.FindIndex(p => p.Id == product.Id);
if (index >= 0)
{
_products[index] = product;
using (Computed.Invalidate())
{
_ = GetAllAsync(cancellationToken);
_ = GetByIdAsync(product.Id, cancellationToken);
}
}
}
}
Blazor Component Integration
Fusion provides ComputedStateComponent<T> as a base class that automatically subscribes to computed values and re-renders when they change.
@page "/products"
@using Stl.Fusion.Blazor
@using MyApp.Services
@inherits ComputedStateComponent<List<Product>>
<h3>Products (@State.Value?.Count ?? 0)</h3>
@if (State.HasValue)
{
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Price</th>
<th>Stock</th>
</tr>
</thead>
<tbody>
@foreach (var product in State.Value)
{
<tr>
<td>@product.Name</td>
<td>@product.Price.ToString("C")</td>
<td>@product.StockCount</td>
</tr>
}
</tbody>
</table>
}
else if (State.Error is not null)
{
<div class="alert alert-danger">Error: @State.Error.Message</div>
}
else
{
<div>Loading...</div>
}
@code {
[Inject] private IProductService ProductService { get; set; } = default!;
protected override async Task<List<Product>> ComputeState(CancellationToken cancellationToken)
{
return await ProductService.GetAllAsync(cancellationToken);
}
}
Invalidation Patterns
Fusion uses explicit invalidation. When data changes, you invalidate the affected compute methods and Fusion propagates the change through the dependency graph.
using Stl.Fusion;
namespace MyApp.Services;
public class OrderService : IOrderService
{
private readonly IProductService _productService;
public OrderService(IProductService productService)
{
_productService = productService;
}
// This compute method depends on GetByIdAsync -- Fusion tracks the dependency
[ComputeMethod]
public virtual async Task<decimal> GetOrderTotalAsync(
int[] productIds, CancellationToken cancellationToken = default)
{
decimal total = 0;
foreach (int id in productIds)
{
var product = await _productService.GetByIdAsync(id, cancellationToken);
if (product is not null)
total += product.Price;
}
return total;
}
public virtual async Task PlaceOrderAsync(
Order order, CancellationToken cancellationToken = default)
{
// Process order...
// When product stock changes, invalidate the product
foreach (var item in order.Items)
{
var product = await _productService.GetByIdAsync(
item.ProductId, cancellationToken);
if (product is not null)
{
var updated = product with
{
StockCount = product.StockCount - item.Quantity
};
await _productService.UpdateAsync(updated, cancellationToken);
}
}
}
}
public record Order(int Id, OrderItem[] Items);
public record OrderItem(int ProductId, int Quantity);
public interface IOrderService : IComputeService
{
[ComputeMethod]
Task<decimal> GetOrderTotalAsync(
int[] productIds, CancellationToken cancellationToken = default);
Task PlaceOrderAsync(
Order order, CancellationToken cancellationToken = default);
}
Service Registration
// Program.cs
using Stl.Fusion;
using MyApp.Services;
var builder = WebApplication.CreateBuilder(args);
var fusion = builder.Services.AddFusion();
fusion.AddService<IProductService, ProductService>();
fusion.AddService<IOrderService, OrderService>();
fusion.AddBlazor(); // Adds Blazor-specific Fusion services
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
Computed State Options
Configure update behavior on components.
@inherits ComputedStateComponent<DashboardData>
@code {
[Inject] private IDashboardService DashboardService { get; set; } = default!;
protected override ComputedState<DashboardData>.Options GetStateOptions()
{
return new()
{
// How often to check for updates
UpdateDelayer = FixedDelayer.Get(TimeSpan.FromSeconds(1)),
// Initial value before first computation
InitialValue = new DashboardData(0, 0, 0m)
};
}
protected override async Task<DashboardData> ComputeState(
CancellationToken cancellationToken)
{
return await DashboardService.GetDashboardAsync(cancellationToken);
}
}
Fusion vs. Traditional Blazor State Management
| Feature | Stl.Fusion | Fluxor / Redux | Cascading Values |
|---|---|---|---|
| Real-time updates | Automatic via invalidation | Manual dispatch required | Manual parameter flow |
| Caching | Built-in computed cache | Manual memoization | None |
| Dependency tracking | Automatic (call graph) | Manual selector composition | None |
| Server-client sync | Transparent replication | Not built-in | Not built-in |
| Boilerplate | ComputeMethod + Invalidate | Actions, reducers, effects | Properties, callbacks |
| Learning curve | Moderate (invalidation model) | High (Redux concepts) | Low |
| Multi-user broadcast | Built-in | Requires SignalR | Not applicable |
Best Practices
-
Make compute method implementations
virtualbecause Fusion uses Castle.DynamicProxy to intercept method calls and manage the computed cache; non-virtual methods bypass the proxy and produce stale data. -
Always invalidate compute methods inside a
using (Computed.Invalidate())block and call the same method signatures that need to be refreshed; Fusion matches invalidation targets by method identity and argument values. -
Register compute services as singletons because Fusion caches computed results per-service-instance; transient or scoped registrations create new instances that bypass the cache and invalidation graph.
-
Inherit from
ComputedStateComponent<T>for Blazor components that consume compute methods rather than calling compute methods directly inOnInitializedAsync, so that the component automatically re-renders when the computed value is invalidated. -
Invalidate only the specific compute methods affected by a mutation rather than invalidating broadly; for example, when updating a single product, invalidate
GetByIdAsync(productId)andGetAllAsync()but not unrelated compute methods. -
Set
UpdateDelayerinGetStateOptions()to control how frequently a component polls for recomputation to balance responsiveness against server load; useFixedDelayer.Get(TimeSpan.FromSeconds(1))for dashboards and shorter intervals for critical data. -
Use Fusion's
IComputeServiceinterface as a marker on service interfaces to enable the Fusion DI extensions to register the proxy wrapper automatically duringAddService<TInterface, TImplementation>(). -
Do not throw exceptions from compute methods to signal "not found" -- return
nullor empty collections instead, because exceptions bypass the computed cache and force recomputation on every access. -
Test compute services by verifying that calling a compute method twice returns the same cached instance and that invalidation causes the next call to return a fresh value, ensuring the caching and invalidation graph works correctly.
-
Separate mutation methods (Add, Update, Delete) from compute methods (Get, List, Count) on the service interface because mutations trigger invalidation while compute methods participate in the dependency graph; mixing them creates confusing invalidation cycles.