UI frameworks¶
Targets: net10.0, csharp-14 · Last reviewed: 2026-09-14 · Sources: ms-learn, aspnet-blog, meziantou, gerald-versluis, james-montemagno, avalonia-blog
Blazor for web UI, .NET MAUI for mobile + desktop, Avalonia for cross-platform desktop. Pick per app; mix render modes within a Blazor app deliberately.
Blazor¶
- Choose render mode per page, not per app. (Microsoft Learn: ASP.NET Core Blazor render modes)
- Use
[PersistentState]for state that must survive prerendering and circuit loss instead of ad-hoc session storage. (Microsoft Learn: ASP.NET Core Blazor prerendered state persistence) - Use
NavigationManager.NotFound()for missing resources, and show the not-found state in the UI too. It sets a 404 status only under static SSR or during prerendering; once a component is interactive it renders Not Found content without one. (Microsoft Learn: ASP.NET Core Blazor navigation) - In interactive-server rendering, get the user's time zone from the browser via a scoped per-circuit
TimeProvider, because the process's local zone is the server's and means nothing to the user. Register a scopedTimeProviderthat overridesLocalTimeZonewith the id from JS interop (Intl.DateTimeFormat().resolvedOptions().timeZone), validated withTimeZoneInfo.TryFindSystemTimeZoneById; components then render user-local wherever time is injected rather than read fromDateTimeOffset.Now(see datetime.md). Two caveats: the interop only runs inOnAfterRenderAsyncunderInteractiveServer, so the first render shows the wrong zone. Design for one corrected re-render rather than pretending the zone is known at first paint, and handleJSDisconnectedException. (Meziantou: Convert DateTime to the user's time zone with Blazor) - Read WebAssembly
HttpClientresponses asynchronously: streaming is on by default in .NET 10, and it is a breaking change.response.Content.ReadAsStreamAsync()now hands back aBrowserHttpReadStreamrather than aMemoryStream, and that stream refuses synchronous calls such asStream.Read(Span<byte>). Fix the call site instead of the default, because streaming is what keeps a large response off the heap. Where a dependency you do not own makes the synchronous call, the opt-outs are per-request (requestMessage.SetBrowserResponseStreamingEnabled(false)), per-project (<WasmEnableStreamingResponse>false</WasmEnableStreamingResponse>), and by environment variable. (Microsoft Learn: What's new in ASP.NET Core 10, last updated 2026-08-31) - A WebAssembly app loads only a subset of globalization data, covering its own culture. Set
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>if the user can switch culture at runtime, and drop the superseded<BlazorEnableTimeZoneSupport>. (globalization.md)
Component testing¶
- Unit-test components with bUnit; reserve Playwright for end-to-end. There is no official Microsoft component-testing framework; Microsoft Learn points at bUnit, which renders components in-process with no browser. Assert with
MarkupMatches, never raw string equality on markup: it compares HTML semantically, so insignificant whitespace and attribute order don't fail the test. Behaviour that depends on real JS interop or browser DOM manipulation is E2E territory: test it with Playwright, not by fakingIJSRuntimeinto meaninglessness. (Microsoft Learn: Test components in ASP.NET Core Blazor) - Use bUnit 2.x with xUnit v3 to match this repository's testing stack (opinions/testing.md); in 2.x the test class inherits
BunitContextand renders withRender<T>(). (bUnit: Writing tests)
public class CounterTests : BunitContext
{
[Fact]
public void Counter_ClickingButton_IncrementsCount()
{
// Arrange
var cut = Render<Counter>();
// Act
cut.Find("button").Click();
// Assert
cut.Find("[role=status]").MarkupMatches(
"""<p role="status">Current count: 1</p>""");
}
}
.NET MAUI¶
- Abstract platform billing/purchases behind a single interface (
IBillingService) with conditional compilation per store, and always validate purchases server-side. (Versluis: Implementing Cross-Platform In-App Billing in .NET MAUI Applications) - Enable Material 3 on Android (
<UseMaterial3>true</UseMaterial3>, MAUI 10.0.60+) for current-generation theming. (Versluis: Give Your .NET MAUI Android Apps a Material 3 Makeover) - Keep trimming on, and exclude a problem assembly rather than disabling it: root it in a
Linker.xmlreferenced by<TrimmerRootDescriptor Include="Linker.xml" />. TurningPublishTrimmedoff is a diagnostic step to confirm trimming is the cause, not the fix. (Versluis: Excluding Assemblies from Trimming in .NET MAUI) - Google Play's 16 KB page-size requirement is native-library alignment, not trimming, and targeting .NET 9 or later meets it out of the box. (Versluis: Preparing Your .NET MAUI Apps for Google Play's 16 KB Page Size Requirement)
- Test preview SDKs with
sdk.pathsinglobal.jsonrather than polluting the machine default, and ignore the.dotnet/folder it installs into. Setup and caveats are in project-structure.md. (Versluis: Test .NET MAUI Preview SDKs Locally with global.json sdk.paths)
App lifecycle¶
- Handle lifecycle with the cross-platform
Windowevents, not per-platform code. SubclassWindow, override the events you need (OnCreated,OnActivated,OnDeactivated,OnStopped,OnResumed,OnDestroying;OnBackgroundingon iOS/Mac Catalyst), and return it fromApp.CreateWindow. InStopped, disconnect long-running work and cancel pending requests; inResumed, resubscribe and refresh visible content. Drop toConfigureLifecycleEventsinMauiProgramonly when a platform-specific hook has no cross-platform event. (Microsoft Learn: .NET MAUI app lifecycle)
public class MainWindow : Window
{
protected override void OnStopped() => _sync.PauseBackgroundSync();
protected override void OnResumed() => _sync.ResumeAndRefresh();
}
Offline & sync¶
- Build mobile apps offline-first: the local store is the source of truth, the network is a sync detail. Cache remote data locally with explicit expiry: SQLite-net for queryable data, or MonkeyCache's
Barrel.Current.Add(key, data, expireIn, eTag)for simple payload caching; the ETag overloads let you skip re-downloading unchanged responses. (Montemagno: Data caching made simple with Monkey Cache) - Gate remote calls on
Connectivity.NetworkAccess == NetworkAccess.Internetand react toConnectivityChanged; never ping-test reachability. The oldIsReachable-style APIs were dropped deliberately, so make the real request and handle failure. (Montemagno: Upgrading to Xamarin.Essentials from Plugins)
Avalonia & reactive UI¶
- Keep view models free of UI-framework references. Property-change notification (
INotifyPropertyChanged) and Rx/Dynamic Data types are platform-neutral. A view model with no Avalonia or WPF reference binds unchanged on either framework and unit-tests without a UI. (Avalonia Docs: The MVVM pattern) - For reactive collection state in XAML UIs, use Dynamic Data over hand-rolled
ObservableCollectionplumbing. Key entities in aSourceCache<T, TKey>and project filtered/sorted views into a bindable collection withConnect(). Updates, filters and sorts then compose instead of being re-implemented per screen. (Avalonia Docs: Sorting, filtering, and grouping collections)
var orders = new SourceCache<Order, int>(o => o.Id);
ReadOnlyObservableCollection<Order> openOrders;
using var subscription = orders.Connect()
.Filter(o => o.Status == OrderStatus.Open)
.SortBy(o => o.Placed)
.Bind(out openOrders) // the view binds to openOrders; only mutate the cache
.Subscribe();
orders.AddOrUpdate(new Order(1, OrderStatus.Open, DateTimeOffset.UtcNow));
Avalonia 12¶
- Target Avalonia 12 for new cross-platform desktop apps. Released 2026-04-07 on .NET 10 and SkiaSharp 3.0, it is a foundations release: the compositor was fundamentally reworked, Android finally has a real
IDispatcherImploverLooper/MessageQueue, and Mac Catalyst is enabled inAvalonia.iOS. Budget for the removals when migrating: Direct2D1, Tizen support,Avalonia.Browser.Blazor,BinaryFormatterusage, and netstandard2.0 from almost all projects are gone. (Avalonia 12: Ready for What's Next) - Keep compiled bindings on, as they are by default in 12. Reflection-based bindings are the per-binding opt-out for dynamic cases, not the house style; compiled bindings fail at build time instead of silently binding to nothing. (Avalonia 12: Ready for What's Next)
- Embed web content with the built-in WebView, not a bundled Chromium. The WebView was open-sourced in 12 (previously a commercial Accelerate component) and renders through each platform's native engine, so app size stays flat. The Accelerate brand itself was retired at the same release, so check what a "commercial-only" Avalonia answer costs before you route around it. (Avalonia: The Avalonia WebView Is Going Open-Source, Avalonia: Retiring Accelerate: One Brand, One Clear Path)
- Use the built-in page navigation for mobile-shaped Avalonia apps:
ContentPage,DrawerPage,CarouselPage,TabView, andPipsPagership in 12 with gesture and wrap-selection support. Hand-rolled navigation stacks over aContentControlwere the pre-12 workaround; retire them. (Avalonia 12: Ready for What's Next) - Avalonia's XAML hot reload works as of 2026-09-02, and it is a paid tier.
AvaloniaUI.DiagnosticsSupport.HotReloadwent GA across Windows, macOS, Linux, iOS and Android, driven bydotnet watchand built on the same .NET Hot Reload machinery MAUI uses rather than an Avalonia-specific mechanism, patching changed elements in place so view state survives the edit. It needs anAvaloniaUILicenseKeyand a Plus, Pro or Enterprise subscription, which is the commercial-component question above in its current form. (Avalonia: Hot Reload for Avalonia) -
Don't build on Impeller yet. The Impeller backend, Avalonia's collaboration with Google's Flutter team to bring a GPU-first renderer to .NET, is experimental. A maintainer put it on pause on 2026-03-09 to ship v12, and no resumption had been announced by 2026-09-24, well after v12's release. Stay on the default Skia renderer and treat Impeller as a "coming next" item. (Avalonia: Avalonia Partnering with Google's Flutter Team to Bring Impeller Rendering to .NET; Avalonia: Impeller rendering subsystem, discussion #20838)
-
Source-redundancy note: Avalonia guidance is the thinnest-sourced material in this repository, and got thinner on 2026-08-23 when
nick-polyak, the one independent voice on the bench, was demoted to the watch list for dormancy, taking his back catalogue with him. What remains isavalonia-blogandawesome-avalonia(discovery-only), both admitted 2026-08-14 under the lowered longevity bars, and both the project's own output. They are authoritative on what shipped and on the documented API, and adoption-focused on whether you should want it, so read release claims, the headline FPS numbers especially, as vendor benchmarks. The split matters more than the thinness: cite the docs freely for mechanism (how a binding, a control or a collection view behaves) the way csharp.md citesms-learn, and treat a judgment about whether to adopt something as uncorroborated until an independent source carries it. Nothing on the bench can play that second part today, so it is a standing gap forvet-sourcerather than a settled bench.