Stateful interactive components for Blazor Static SSR — no SignalR, no WebAssembly.
ReactiveBlazor lets you build interactive server-rendered Blazor components that respond to user input via standard HTTP round-trips. Every click, change, or keypress dispatches a fetch POST to the server, which re-renders the component and morphs the DOM in place using Idiomorph.
- Zero client-side code required — ~280 lines of vanilla JS inside the library, no developer-written JS or build steps needed.
- Strongly-typed signals for OOB updates — Actions publish typed
IReactiveSignalrecords; components opt in with[OnReactiveSignal<T>]to be re-rendered out-of-band. No tight coupling through shared services required. - Signed & encrypted state — Component state is protected with ASP.NET Data Protection to prevent tampering.
- Time-limited tokens — State tokens expire after a configurable lifetime (default: 24 hours) to prevent stale submissions.
- One-Time Use Tokens (Anti-Replay) — Nonce validation to protect non-idempotent actions from duplicate replay.
- CSRF protected — Antiforgery tokens are automatically validated on every request.
- DOM morphing — Idiomorph preserves focus, text selection, scroll position, and CSS transitions.
- Request queuing — Rapid clicks or inputs are serialized per component to prevent race conditions.
- Two-way binding —
data-bindsyncs input values (text, dropdowns, checkboxes, radios) back to component properties. - Debounce support —
data-debounce="300"for search and text inputs to reduce network load. - Redirect support — Set
RedirectUrlin an action to navigate the browser to a new URL after processing. - Multi-target — Supports .NET 8, .NET 9, and .NET 10.
dotnet add package ReactiveBlazorIn your Program.cs, register the required services:
// Program.cs
builder.Services.AddDataProtection(); // Required — configure secure key storage for production
builder.Services.AddReactiveComponents(assemblies: typeof(Program).Assembly);
var app = builder.Build();
// After app.MapRazorComponents<App>()
app.MapReactiveComponents();
app.Run();Add <ReactiveScripts /> and the default loading indicator styles to the <head> of your root component:
<head>
<!-- ... -->
<ReactiveScripts />
<!-- Optional: include the default loading indicator style (fades out busy components) -->
<link rel="stylesheet" href="/_content/ReactiveBlazor/reactive.css" />
</head>Inherit from ReactiveComponent, wrap your markup in <ReactiveRoot>, and declare public properties and action methods:
@inherits ReactiveBlazor.ReactiveComponent
<ReactiveRoot Owner="this">
<p>Count: @Count</p>
<button type="button" class="btn" data-on-click="Increment">+1</button>
<button type="button" class="btn" data-on-click="Add" data-args="[5]">+5</button>
</ReactiveRoot>
@code {
// Public properties represent state. They are encrypted and serialized automatically.
public int Count { get; set; }
// Methods decorated with [ReactiveAction] can be called from client events.
[ReactiveAction]
public void Increment() => Count++;
[ReactiveAction]
public void Add(int amount) => Count += amount;
}In static SSR, components are typically isolated. ReactiveBlazor lets you keep them in sync without coupling them through shared services or writing any client-side glue — just publish a typed signal from an action, and every subscribed component on the page is re-rendered out-of-band in the same dispatch response.
Any record (or class) that implements IReactiveSignal is a signal. Payloads are optional:
public sealed record CartChanged : IReactiveSignal;
public sealed record NotificationAdded(int Id, string Level) : IReactiveSignal;Every ReactiveComponent has access to ReactiveSignals for publishing:
[ReactiveAction]
public void AddToCart(int productId)
{
_cart.Add(productId);
ReactiveSignals.Publish<CartChanged>();
// Or with a payload:
// ReactiveSignals.Publish(new NotificationAdded(id: 42, level: "info"));
}Three overloads are available:
ReactiveSignals.Publish<CartChanged>(); // default-constructed
ReactiveSignals.Publish(new NotificationAdded(42, "info")); // instance with payload
ReactiveSignals.Publish(typeof(CartChanged)); // runtime TypeDecorate the consumer class with [OnReactiveSignal<T>]. Stack the attribute to subscribe
to multiple signals:
@inherits ReactiveBlazor.ReactiveComponent
@attribute [OnReactiveSignal<CartChanged>]
<ReactiveRoot Owner="this">
<span class="badge">@ItemCount</span>
</ReactiveRoot>
@code {
public int ItemCount { get; set; }
protected override void OnInitialized() => ItemCount = _cart.Count();
}@attribute [OnReactiveSignal<NotificationAdded>]
@attribute [OnReactiveSignal<NotificationRead>]
@attribute [OnReactiveSignal<NotificationsCleared>]The class attribute alone is enough to get a component re-rendered. If you also want the
data that was published — not just a refresh — query the bus from inside a Blazor
lifecycle method using the same ReactiveSignals property you publish with:
@inherits ReactiveBlazor.ReactiveComponent
@attribute [OnReactiveSignal<NotificationAdded>]
@attribute [OnReactiveSignal<NotificationsCleared>]
@inject NotificationService Notifications
<ReactiveRoot Owner="this">
<span>🔔 @UnreadCount</span>
@if (LastToast is not null)
{
<div class="alert alert-@LastToast.Level">#@LastToast.Id — @LastToast.Message</div>
}
</ReactiveRoot>
@code {
public int UnreadCount { get; set; }
public NotificationAdded? LastToast { get; set; }
protected override void OnInitialized()
{
// Always refresh from source of truth
UnreadCount = Notifications.UnreadCount();
// Read every NotificationAdded published this dispatch (empty if none)
foreach (var s in ReactiveSignals.GetPublished<NotificationAdded>())
LastToast = s;
// Cheap boolean check for payload-less signals
if (ReactiveSignals.WasPublished<NotificationsCleared>())
LastToast = null;
}
}Three query methods are available on IReactiveSignals:
IEnumerable<T> GetPublished<T>() where T : IReactiveSignal; // payloads, in publish order
bool WasPublished<T>() where T : IReactiveSignal; // any of T published?
bool WasPublished(Type signalType); // runtime formPolymorphic queries work too — GetPublished<ICartSignal>() returns every published signal
whose type is assignable to ICartSignal.
For every dispatch:
- The client sends the state of the target component plus the states of every other reactive component currently on the page.
- The server runs the action on the target, which may publish zero or more signals into
the per-request
IReactiveSignalsbus. - The server collects every component type subscribed to a published signal via
[OnReactiveSignal<T>]and re-renders the corresponding instances on the page. - The response returns a JSON dictionary of
id → htmlcontaining only the target + the matched subscribers — not every component on the page. - The client morphs each entry into the DOM using Idiomorph.
Components that don't subscribe to any published signal are not re-rendered, even if they were sent up in the request. This keeps updates targeted and avoids unintended side-effects on unrelated components.
The demo project includes a multi-signal page at
/notificationsshowing three signal types, three subscribers (each subscribed to a different combination), and one isolated component to verify that non-subscribers are skipped.
Decorate HTML elements inside <ReactiveRoot> to connect them to C# actions and properties:
| Attribute | Description |
|---|---|
data-on-click="ActionName" |
Invoke an action on click |
data-on-change="ActionName" |
Invoke an action when the input value changes |
data-on-input="ActionName" |
Invoke an action on text input |
data-on-submit="ActionName" |
Invoke an action on form submit (prevents default postback) |
data-on-keydown="ActionName" |
Invoke on keydown |
data-bind="PropertyName" |
Two-way bind an input's value to a C# property |
data-args="[1, \"hello\"]" |
Pass arguments (serialized as a JSON array) to the action method |
data-debounce="300" |
Delay dispatch by N milliseconds (ideal for inputs and search boxes) |
data-queue="all" |
Queue every request (default is latest, which drops intermediate requests) |
Customize limits and behavior during service registration:
builder.Services.AddReactiveComponents(options =>
{
options.MaxStateBytes = 128 * 1024; // Max state size (default: 64KB)
options.MaxTokenBytes = 512 * 1024; // Max encrypted token size (default: 256KB)
options.StateTokenLifetime = TimeSpan.FromHours(12); // Token expiry (default: 24h)
options.DispatchPath = "/_reactive/dispatch"; // Custom dispatch endpoint (default)
}, assemblies: typeof(Program).Assembly);Use [ReactiveIgnore] on public properties that shouldn't be serialized into the page token (e.g. static lists, read-only cache data):
[ReactiveIgnore]
public string[] StaticOptions { get; set; } = ["Classic", "Cyberpunk", "Forest"];While a dispatch is in-flight, the library adds the data-reactive-busy attribute and the reactive-loading CSS class to the component's root element.
Include the default stylesheet for built-in styling (adds opacity fade and disables mouse clicks):
<link rel="stylesheet" href="/_content/ReactiveBlazor/reactive.css" />Or write custom CSS rules:
[data-reactive-busy] {
pointer-events: none;
opacity: 0.6;
transition: opacity 0.2s ease;
}By default, ASP.NET Core Data Protection uses an ephemeral in-memory key store or a local filesystem store.
Warning
If your application restarts, runs inside transient containers (like Docker/Kubernetes), or is scaled horizontally behind a load balancer (High Availability), the keys used to encrypt state tokens will mismatch or be lost. This will result in immediate decryption failures (400 Bad Request) for your users.
For single-server VM setups (surviving server restarts without external databases/caches):
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"C:\app-keys\")) // Survives server restarts
.ProtectKeysWithDpapi(); // Or DPAPI-NG / X.509 CertificateFor load-balanced, multi-instance, or container environments (HA):
builder.Services.AddDataProtection()
.PersistKeysToDbContext<MyDbContext>() // Or PersistKeysToStackExchangeRedis()
.ProtectKeysWithAzureKeyVault(...); // Or ProtectKeysWithDpapi() / CertsFor non-idempotent actions (like checkouts, processing payments, or adding database records), you can prevent users from resending/replaying the same interaction request within the token lifetime.
Decorate your critical actions with RequireOneTimeToken:
[ReactiveAction(RequireOneTimeToken = true)]
public void ProcessPayment()
{
// This action can only be invoked once per state token payload.
}By default, nonces are tracked in local memory. If you are running multiple instances of your application, you must replace the default in-memory store with a shared/distributed nonce store by implementing IReactiveNonceStore:
public class RedisNonceStore : IReactiveNonceStore
{
private readonly IDatabase _redis;
public RedisNonceStore(IConnectionMultiplexer redis) => _redis = redis.GetDatabase();
public bool TryConsume(string nonce, TimeSpan lifetime)
{
// Try to set the key in Redis with PX (expire) and NX (set if not exists)
return _redis.StringSet($"nonce:{nonce}", "used", lifetime, When.NotExists);
}
}And register it in your Program.cs:
builder.Services.AddSingleton<IReactiveNonceStore, RedisNonceStore>();
builder.Services.AddReactiveComponents(); // Will automatically skip registering the in-memory fallbackIf you want one-time action tokens to survive server restarts on a single VM without deploying a Redis cache, you can implement a disk-backed store using a local SQLite database:
using Microsoft.Data.Sqlite;
public class SqliteNonceStore : IReactiveNonceStore
{
private readonly string _connectionString = "Data Source=nonces.db";
public SqliteNonceStore()
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
var cmd = connection.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS ConsumedNonces (Nonce TEXT PRIMARY KEY, ExpiresAt DATETIME)";
cmd.ExecuteNonQuery();
}
public bool TryConsume(string nonce, TimeSpan lifetime)
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
// Cleanup expired nonces
var deleteCmd = connection.CreateCommand();
deleteCmd.CommandText = "DELETE FROM ConsumedNonces WHERE ExpiresAt < @now";
deleteCmd.Parameters.AddWithValue("@now", DateTime.UtcNow);
deleteCmd.ExecuteNonQuery();
// Insert new nonce
try
{
var insertCmd = connection.CreateCommand();
insertCmd.CommandText = "INSERT INTO ConsumedNonces (Nonce, ExpiresAt) VALUES (@nonce, @expires)";
insertCmd.Parameters.AddWithValue("@nonce", nonce);
insertCmd.Parameters.AddWithValue("@expires", DateTime.UtcNow.Add(lifetime));
insertCmd.ExecuteNonQuery();
return true;
}
catch (SqliteException) // Unique constraint violation (nonce already used)
{
return false;
}
}
}Register it in your Program.cs:
builder.Services.AddSingleton<IReactiveNonceStore, SqliteNonceStore>();Every public method marked with [ReactiveAction] is exposed as an endpoint that can be remotely invoked.
Important
Do not rely on hiding buttons or elements in your Blazor markup to prevent users from executing actions. An attacker can easily read the state token from the DOM and fire a custom fetch POST request.
You must perform all authorization, validation, and business rule checks inside the action method itself:
[ReactiveAction]
public void DeleteRecord(int id)
{
if (!User.IsInRole("Admin")) throw new UnauthorizedAccessException();
// Delete code...
}By default, ReactiveBlazor uses an opt-out model: all public read/write properties are automatically serialized into the state token unless they are decorated with [ReactiveIgnore].
To prevent accidental exposure of sensitive properties, you can switch to an opt-in model:
- Enable opt-in in registration options:
builder.Services.AddReactiveComponents(options =>
{
options.RequireOptInState = true;
});- Explicitly decorate properties you want to serialize with
[ReactiveState]:
@inherits ReactiveBlazor.ReactiveComponent
<ReactiveRoot Owner="this">
<p>User Profile for @Username</p>
</ReactiveRoot>
@code {
[ReactiveState]
public string Username { get; set; } // Will be serialized
public string PasswordHash { get; set; } // Ignored (will not be sent to client)
}ReactiveBlazor fills a specific gap: event-driven, granular interactivity on static SSR without a persistent connection. It is not a replacement for Blazor's interactive render modes — pick the right tool:
| Scenario | Recommended approach |
|---|---|
| Public, high-traffic pages where a per-user SignalR circuit is too expensive | ReactiveBlazor |
| Cheap/stateless hosting, autoscaling, or serverless where circuits are impractical | ReactiveBlazor |
| Small islands of interactivity (counters, search, cart badges) on otherwise static pages | ReactiveBlazor |
| Rich, low-latency client apps with lots of fast UI state | Interactive WebAssembly |
| Highly interactive apps where a stateful circuit is acceptable | Interactive Server |
Simple data submission that fits a <form> post |
Built-in enhanced form handling (no library needed) |
Each interaction is a server round-trip, so ReactiveBlazor is best for interactions measured in clicks, not continuous high-frequency input.
- Requires JavaScript. Interactivity is driven by the bundled runtime; there is no no-JS form fallback. Progressive enhancement is an explicit non-goal — if you need it, use built-in enhanced forms for those interactions.
- Whole-page state travels on each dispatch. The client uploads the encrypted state of every reactive component on the page per interaction. Keep per-component state small (see
MaxStateBytes/MaxComponentsPerDispatch). - Trimming / Native AOT. State serialization and action dispatch use reflection and reflection-based
System.Text.Json, so the library is not currently trim-safe or AOT-safe. AvoidPublishTrimmed/PublishAotfor apps using ReactiveBlazor. A source-generator-based path is on the roadmap. - Authorize inside actions. Every
[ReactiveAction]is a remotely invokable endpoint. ThrowUnauthorizedAccessExceptionfor denied access (returned to the client as403); perform all validation server-side (see the Security section).
This project is licensed under the MIT License.
It bundles Idiomorph (BSD 2-Clause); see THIRD-PARTY-NOTICES.txt.