Keep a Workspace Projection Live
Overview
Keep a workspace's account projection subscribed for the lifetime of the Blazor client. A provider in the application shell establishes the interest once, and pages read the resulting state from Reservoir.
This keeps related account screens connected to the same server read model while leaving business rules in the domain. A fixed entity ID, typed DTO, and explicit application lifetime give an AI assistant concrete boundaries for generating and testing those screens.
When to use this
Use this pattern for a workspace with a small, fixed set of entities that should stay live as users navigate between pages. The application shell owns the subscriptions; individual pages own only their store listeners and presentation.
Before you begin
- Start from Spring, with its generated client features and Inlet client composition.
- Choose an existing account ID for the workspace. Replace
doc-account-001in the provider below with that ID before running the client. - Give each DTO type and entity ID one application-level owner. This example keeps the selected account live throughout the client session, including while its display page is closed.
The following files add a provider and a display page to Spring. Keep the provider outside the router so it remains mounted during page navigation.
Steps
1. Add The Application Owner
Create samples/Spring/Spring.Client/Components/AccountProjectionProvider.razor:
@namespace MississippiSamples.Spring.Client.Components
@inherits InletComponent
@using Mississippi.Inlet.Client
Create samples/Spring/Spring.Client/Components/AccountProjectionProvider.razor.cs:
using MississippiSamples.Spring.Client.Features.BankAccountBalance.Dtos;
namespace MississippiSamples.Spring.Client.Components;
/// <summary>
/// Keeps the configured account projection subscribed for the client session.
/// </summary>
public sealed partial class AccountProjectionProvider
{
/// <summary>
/// Identifies the account kept live throughout this client session.
/// </summary>
public const string AccountId = "doc-account-001";
/// <inheritdoc />
protected override void OnAfterRender(bool firstRender)
{
base.OnAfterRender(firstRender);
if (firstRender)
{
SubscribeToProjection<BankAccountBalanceProjectionDto>(AccountId);
}
}
}
The first browser render starts the subscription. Later renders retain the same interest. Inlet performs the asynchronous connection, hub subscription, and initial HTTP read, publishing the result into Reservoir.
2. Mount It Outside The Router
Update samples/Spring/Spring.Client/App.razor to include the provider alongside Spring's existing root components:
@using Mississippi.Reservoir.Client.BuiltIn.Components
@using MississippiSamples.Spring.Client.Components
@namespace MississippiSamples.Spring.Client
<ReservoirNavigationProvider/>
<ReservoirDevToolsInitializerComponent/>
<AccountProjectionProvider/>
<NavLink href="/projection-watch">Workspace balance</NavLink>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
Page navigation can now occur while the initial subscription is pending: the provider and its interest remain part of the application shell. Keep the provider mounted for the client session. The scoped hub-connection provider disposes its connection when its service scope ends.
3. Give Operations The Same Ownership Boundary
Spring's Operations page also manages account projection subscriptions. In samples/Spring/Spring.Client/Pages/OperationsPage.razor.cs, add this import:
using MississippiSamples.Spring.Client.Components;
Replace SyncProjectionSubscription with the following method. The workspace account's balance belongs to the shell; Operations continues owning its ledger and other account balances.
private void SyncProjectionSubscription(
string? currentEntityId,
ref string? subscribedEntityId
)
{
if (string.Equals(currentEntityId, subscribedEntityId, StringComparison.Ordinal))
{
return;
}
UnsubscribeFromAccountProjections(subscribedEntityId);
if (!string.IsNullOrWhiteSpace(currentEntityId))
{
if (!string.Equals(currentEntityId, AccountProjectionProvider.AccountId, StringComparison.Ordinal))
{
SubscribeToProjection<BankAccountBalanceProjectionDto>(currentEntityId);
}
SubscribeToProjection<BankAccountLedgerProjectionDto>(currentEntityId);
}
subscribedEntityId = currentEntityId;
}
Replace UnsubscribeFromAccountProjections with the matching release method:
private void UnsubscribeFromAccountProjections(
string? entityId
)
{
if (string.IsNullOrWhiteSpace(entityId))
{
return;
}
if (!string.Equals(entityId, AccountProjectionProvider.AccountId, StringComparison.Ordinal))
{
UnsubscribeFromProjection<BankAccountBalanceProjectionDto>(entityId);
}
UnsubscribeFromProjection<BankAccountLedgerProjectionDto>(entityId);
}
This gives the configured balance pair one owner even when Operations displays it. Apply the same boundary to any additional page that manages that pair's subscriptions.
4. Display Shared State In A Page
Create samples/Spring/Spring.Client/Pages/ProjectionWatch.razor:
@page "/projection-watch"
@namespace MississippiSamples.Spring.Client.Pages
@inherits InletComponent
@using Mississippi.Inlet.Client
@using Mississippi.Inlet.Client.SignalRConnection
@using MississippiSamples.Spring.Client.Features.BankAccountBalance.Dtos
<h1>Account balance</h1>
<p>Connection: @(GetState<SignalRConnectionState>().Status)</p>
@if (IsProjectionLoading<BankAccountBalanceProjectionDto>(AccountId))
{
<p role="status">Loading account…</p>
}
else if (GetProjectionError<BankAccountBalanceProjectionDto>(AccountId) is not null)
{
<p role="alert">The account could not be loaded or subscribed. Check gateway access, then reconnect the workspace.</p>
}
else if (GetProjection<BankAccountBalanceProjectionDto>(AccountId) is { } account)
{
<p>@account.HolderName: @account.Balance</p>
<p>Version: @(GetProjectionState<BankAccountBalanceProjectionDto>(AccountId)?.Version)</p>
}
else
{
<p>No account data has been loaded.</p>
}
<button type="button" @onclick="RefreshCurrent">Refresh</button>
<button type="button" @onclick="ReloadWorkspace">Reconnect workspace</button>
Create samples/Spring/Spring.Client/Pages/ProjectionWatch.razor.cs:
using Microsoft.AspNetCore.Components;
using MississippiSamples.Spring.Client.Components;
using MississippiSamples.Spring.Client.Features.BankAccountBalance.Dtos;
namespace MississippiSamples.Spring.Client.Pages;
/// <summary>
/// Displays the application-owned account projection.
/// </summary>
public sealed partial class ProjectionWatch
{
private const string AccountId = AccountProjectionProvider.AccountId;
[Inject]
private NavigationManager Navigation { get; set; } = default!;
private void ReloadWorkspace() =>
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
private void RefreshCurrent() =>
RefreshProjection<BankAccountBalanceProjectionDto>(AccountId);
}
InletComponent inherits Reservoir's store subscription and render lifecycle. Disposing this display page releases its store listener. The application provider continues owning the live account interest, ready for other pages or a return visit.
5. Present Fetch And Transport State
Use IsProjectionLoading<T>() and GetProjectionError<T>() for the entity's fetch state. GetProjection<T>() returns its DTO when available, and GetProjectionState<T>() exposes its version.
The initial fetch maps HTTP 404 to NotFound: no projection data is available yet. Inlet publishes a loaded result with null DTO data and retains the active subscription so later events can provide data. Present this separately from a fetch error. Give this state a useful presentation, such as an invitation to open the account.
Read SignalRConnectionState.Status for the shared transport indicator. Projection entry IsConnected is separately controlled by projection connection actions; use the transport feature for the connection display above.
RefreshProjection<T>(entityId) requests the latest projection over HTTP and publishes the result into Reservoir. It does not retry a failed hub subscription. Inlet re-establishes active interests and refreshes them after a successful SignalR reconnection, but a failed initial subscription is not an active interest. Keep the loading, empty, error, and data presentation usable throughout that process.
If the initial connection or hub subscription fails, restore gateway access or correct the subscription policy, then select Reconnect workspace. This performs a full client reload, initializes a fresh store, and starts the application owner's subscription again. Local Reservoir state is reset by that reload; the account data remains on the server. Use Refresh to retry a data read only after the subscription is established. A successful HTTP refresh alone does not prove that live updates are active.
Verify the result
From the repository root, build the sample:
pwsh ./build.ps1 -SkipMississippi -Configuration Release
Run Spring using the sample startup instructions, then open /projection-watch.
- Confirm the configured account's holder, balance, and version appear after the initial fetch.
- Leave the page open and deposit into the same account from another browser tab. Confirm the displayed projection updates.
- Select the configured account in Operations, then follow the Workspace balance link. Deposit into that account from another browser tab and confirm the watch page still receives updates after Operations closes.
- Repeat navigation while the initial projection request is delayed in browser Network tools. The application owner remains mounted while the request finishes.
- Click Refresh and confirm the latest data returns.
- Block the initial hub negotiation in browser Network tools, reload, then restore connectivity and select Reconnect workspace. Confirm the account loads and a later deposit still updates the page.
- If a hub subscription invocation fails after connection, restore access and select Reconnect workspace. Confirm that a later deposit updates the page; an HTTP-only Refresh is not sufficient verification.
For Spring's existing automated browser validation, run pwsh ./test-spring.ps1 -Doctor and then pwsh ./test-spring.ps1. A PASS summary means tests executed successfully; the manual checks above exercise the additional workspace page specifically.
Source Code
- InletComponent.cs defines the page helpers; StoreComponent.cs manages component store listeners.
- App.razor supplies the existing Spring application shell.
- InletSignalRActionEffect.cs implements subscription, fetch, and reconnect behavior.
- HubConnectionProvider.cs owns the scoped connection.
- ProjectionsReducer.cs and SignalRConnectionState.cs define the state consumed by the page.
Summary
Mount a fixed workspace's subscription owner in the application shell, share its projection state between pages, and give each page its own presentation and store-listener lifecycle.
Next Steps
- Generated application contracts explains how the DTO, path, notification, and HTTP read fit together.
- Reservoir state flow explains dispatch, effects, and component updates.
- Enable DevTools to inspect projection actions and state during development.