Skip to content

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 scoped TimeProvider that overrides LocalTimeZone with the id from JS interop (Intl.DateTimeFormat().resolvedOptions().timeZone), validated with TimeZoneInfo.TryFindSystemTimeZoneById; components then render user-local wherever time is injected rather than read from DateTimeOffset.Now (see datetime.md). Two caveats: the interop only runs in OnAfterRenderAsync under InteractiveServer, 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 handle JSDisconnectedException. (Meziantou: Convert DateTime to the user's time zone with Blazor)
  • Read WebAssembly HttpClient responses asynchronously: streaming is on by default in .NET 10, and it is a breaking change. response.Content.ReadAsStreamAsync() now hands back a BrowserHttpReadStream rather than a MemoryStream, and that stream refuses synchronous calls such as Stream.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 faking IJSRuntime into 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 BunitContext and renders with Render<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

App lifecycle

  • Handle lifecycle with the cross-platform Window events, not per-platform code. Subclass Window, override the events you need (OnCreated, OnActivated, OnDeactivated, OnStopped, OnResumed, OnDestroying; OnBackgrounding on iOS/Mac Catalyst), and return it from App.CreateWindow. In Stopped, disconnect long-running work and cancel pending requests; in Resumed, resubscribe and refresh visible content. Drop to ConfigureLifecycleEvents in MauiProgram only 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.Internet and react to ConnectivityChanged; never ping-test reachability. The old IsReachable-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 ObservableCollection plumbing. Key entities in a SourceCache<T, TKey> and project filtered/sorted views into a bindable collection with Connect(). 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 IDispatcherImpl over Looper/MessageQueue, and Mac Catalyst is enabled in Avalonia.iOS. Budget for the removals when migrating: Direct2D1, Tizen support, Avalonia.Browser.Blazor, BinaryFormatter usage, 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, and PipsPager ship in 12 with gesture and wrap-selection support. Hand-rolled navigation stacks over a ContentControl were 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.HotReload went GA across Windows, macOS, Linux, iOS and Android, driven by dotnet watch and 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 an AvaloniaUILicenseKey and 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 is avalonia-blog and awesome-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 cites ms-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 for vet-source rather than a settled bench.