VodiSoft
The answer to "if I drop XAML I lose hot reload": .NET Hot Reload works at the code level, so C# markup is part of that loop too. We cover the three setup steps, what actually happens when you hit save, and — more importantly — how to write code that assumes Build() will run many times: where state belongs and where subscriptions belong.

Everyone considering C# markup gets stuck on the same sentence: "XAML has hot reload — if I move to C#, I'll have to restart the app on every change."

It is a common assumption, and it is wrong. .NET Hot Reload targets your code, not XAML — it pushes compiled code updates into the running process. XAML Hot Reload is a separate layer built on top of that. With C# markup the only missing piece is something that rebuilds the UI when the code updates.

FmgLib.MauiMarkup supplies that piece. This post covers the setup, what actually happens when you hit save, and — more importantly — how to write code knowing that Build() will run many times.

Setup in three steps

public class CounterPage : ContentPage, IFmgLibHotReload
{
    public CounterPage() => this.InitializeHotReload();

    public void Build() => this
        .Content(
            new Label().Text("Hello").FontSize(32).Center());
}
  1. Implement IFmgLibHotReload on the page (it declares a single void Build()).
  2. Call this.InitializeHotReload() in the constructor.
  3. Put all UI construction inside Build().

The ready-made base classes remove even that plumbing:

public class ProfilePage : FmgLibContentPage<ProfileViewModel>
{
    public ProfilePage(ProfileViewModel vm) : base(vm) { }

    public override void Build() => this
        .Content(
            new VerticalStackLayout()
            .Padding(20)
            .Spacing(12)
            .Children(
                new Label().Text(e => e.Getter(static (ProfileViewModel v) => v.UserName)),

                new Button()
                    .Text("Refresh")
                    .Command(BindingContext.RefreshCommand)   // typed, no cast
            )
        );
}

FmgLibContentPage<TViewModel> takes the view model in the constructor, assigns it to BindingContext before the first Build() runs, and re-types the BindingContext property so you never write ((ProfileViewModel)BindingContext). It drops straight into DI:

builder.Services.AddTransient<ProfileViewModel>();
builder.Services.AddTransient<ProfilePage>();

What happens when you save

The chain looks like this:

  1. Your editor (or dotnet watch) compiles the change and sends it into the running process through the .NET Hot Reload channel.
  2. The runtime notifies the registered MetadataUpdateHandlers. The library's handler is one of them.
  3. The handler calls Build() again, on the main thread, for every registered page.
  4. Because Build() assigns Content again, the visual tree is rebuilt from scratch.

Every update writes a line to the debug output:

FmgLib.MauiMarkup hot reload: update received (types: …) — rebuilding N registered target(s).

If you see that line, the chain works end to end. If you don't, the problem is not your code but the channel delivering the update (more on that below).

Two design details make life easier: registration uses weak references, so hot reload never extends a page's lifetime and leak detectors won't report pages as pinned. And if Build() throws during a reload, the app does not crash — the failure is logged and surfaced through ReloadFailed, so you fix the edit and save again.

The real subject: Build() runs many times

Setup takes five minutes. Everything else comes down to a single mental model:

The visual tree is disposable; your state is not.

Build() will run dozens of times in a session. Each run creates brand-new controls and throws the old ones away. So everything you create inside Build() is temporary, and everything you keep outside it is permanent.

Once that distinction clicks, almost every hot-reload complaint evaporates.

State: in the constructor, in fields

public class CounterPage : ContentPage, IFmgLibHotReload
{
    readonly CounterViewModel vm = new();   // survives every reload

    public CounterPage() => this.InitializeHotReload();

    public void Build() => this
        .BindingContext(vm)
        .Content(
            new VerticalStackLayout()
            .Spacing(16)
            .Padding(24)
            .Center()
            .Children(
                new Label()
                    .FontSize(48)
                    .Text(e => e
                        .Getter(static (CounterViewModel v) => v.Count)
                        .StringFormat("{0}")),

                new Button()
                    .Text("Increment")
                    .Command(e => e.Getter(static (CounterViewModel v) => v.IncrementCommand))
            )
        );
}

Tap the counter up to 47, change a colour, save: the screen redraws and the counter stays at 47 — because the value lives in the view model, and the view model lives in a field.

Move that one line into Build():

public void Build()
{
    var vm = new CounterViewModel();   // ❌ resets the counter on every reload
    ...
}

…and 47 disappears on every save. This is the number one cause of “hot reload resets my state” — and it has nothing to do with hot reload; it is state kept in the wrong place.

Subscriptions: long-lived ones go in the constructor

public CounterPage()
{
    Application.Current!.RequestedThemeChanged += OnThemeChanged;   // ✅ once
    this.InitializeHotReload();
}

Put that inside Build() and every reload adds another subscription: after ten saves, a theme change runs your handler ten times. That is where “I tapped once and the event fired twice” comes from.

The rule is simple: if the object you subscribe to outlives the page (Application.Current, static events, singleton services), the subscription belongs in the constructor. A control's own events — .OnClicked(...), .OnTapped(...) — can stay inside Build(), because the control is recreated along with them.

One-time work: not in Build()

Network calls, animations, data loading — start them in Build() and they fire again on every save:

public class FeedPage : ContentPage, IFmgLibHotReload
{
    readonly List<string> items = new();
    bool loaded;                                   // the flag is a field too

    public FeedPage() => this.InitializeHotReload();

    public void Build() => this
        .Content(
            new CollectionView()
                .ItemsSource(items)
                .OnLoaded(async c => await LoadOnceAsync()));

    async Task LoadOnceAsync()
    {
        if (loaded)
            return;

        loaded = true;
        // ... fetch from the service, add to items ...
    }
}

OnLoaded/OnAppearing is the right place, and keeping the flag in a field is what makes it work: after a reload loaded is still true, so the data is not fetched twice.

Control references: Assign into locals

new Entry().Assign(out var emailEntry).Placeholder("E-mail"),
new Button().OnClicked(b => Validate(emailEntry.Text))

Keep references captured with Assign in local variables inside Build(). Make one a field and, after a reload, that field still points at the old control that is no longer on screen — a silent, hard-to-find bug.

The development loop

The most reliable path is also the most IDE-independent one: dotnet watch.

dotnet watch run -f net10.0-ios

No debugger required. In VS Code, bind it to a single key:

// .vscode/tasks.json
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "🔥 Hot Reload: iOS Simulator",
            "type": "shell",
            "command": "dotnet",
            "args": [ "watch", "run", "-f", "net10.0-ios" ],
            "isBackground": true,
            "problemMatcher": []
        }
    ]
}

Projects created from the fmglib-mauimarkup-app template ship this file already. Start a separate F5 session when you need breakpoints.

Channel support in short:

Channel Status
dotnet watch run ✅ Most reliable, IDE-independent
Visual Studio (F5, Windows) ✅ Full support
VS Code + C# Dev Kit "csharp.experimental.debug.hotReload": true is required
Rider (debugger) ❌ Does not deliver .NET Hot Reload to MAUI — use a dotnet watch run configuration
Plain dotnet run / Release ❌ No update channel — by design, zero overhead

That last row matters: there is no cost in a Release build. MetadataUpdater.IsSupported is false, so the handler never runs and InitializeHotReload() amounts to calling Build() once.

A note for iOS and Mac Catalyst: .NET Hot Reload needs the Mono interpreter. MAUI enables it for Debug by default; if you turned it off, turn it back on for Debug.

When nothing shows up

Always start the diagnosis in the same place: is the update received line in the debug output?

No line. The problem is the channel, not your code — the changes never reach the process. Check the VS Code setting above; otherwise switch to dotnet watch run. The library also logs a one-time warning when the channel is closed (MetadataUpdater.IsSupported = false); if you see it, nothing will apply in that session no matter what you change.

The line is there but the screen doesn't change. The type you edited doesn't rebuild itself. Only types implementing IFmgLibHotReload do; if you edited a helper ContentView, give it the same three steps and it will rebuild itself.

Changes applied but not rendered. Some tooling applies the code update without notifying the runtime handlers. Leave yourself a rescue hatch behind a debug-only gesture:

#if DEBUG
new Border()
    .Content(new Label().Text("dev"))
    .GestureRecognizers(
        new TapGestureRecognizer()
            .NumberOfTapsRequired(3)
            .OnTapped((s, e) => FmgLibHotReloadHandler.RebuildAll()));
#endif

“Rude edit” warnings. Changing a method signature, adding fields to certain types and similar edits exceed what .NET Hot Reload can do; restart the session. That is a runtime limit, not a library one.

If Build() throws during a reload the app stays up, but to see why:

FmgLibHotReloadHandler.ReloadFailed += (target, ex) =>
    Debug.WriteLine($"Build() failed for {target.GetType().Name}: {ex.Message}");

A side effect: hot-reload-friendly code is just good code

Follow these rules and you notice something: the structure you end up with is cleaner regardless of hot reload.

  • State collects in the view model; the visual tree only displays it.
  • Build() becomes a pure declaration — “this screen is this” — with no side effects.
  • Side effects (loading, animation, subscriptions) move into the lifecycle methods where they belong.

Those are the recommended MVVM practices anyway. Hot reload simply gives you instant feedback when you break them: if a counter resets or an event fires twice, your code is telling you something.

Summary

  • .NET Hot Reload looks at code, not XAML; C# markup is not outside the loop.
  • IFmgLibHotReload + InitializeHotReload() + Build() — or just FmgLibContentPage<TViewModel>.
  • The visual tree is disposable, your state is not: view models and flags in fields, UI inside Build().
  • Long-lived subscriptions go in the constructor; control events can stay in Build().
  • Keep Assign references in locals, never in fields.
  • dotnet watch run is the most reliable channel; start every diagnosis from the update received line.
  • Zero overhead in Release.

What you get in the end is this: the simulator is open, you edit the code, you save, and the screen updates in a second or two — with the app's state still intact. That is the fastest way to work on a design, and it needs no XAML.

Related Articles

FmgLib.MauiMarkup or CommunityToolkit.Maui.Markup? An Honest Comparison

There are two ways to build a .NET MAUI UI in C# instead of XAML, and both use fluent methods. Nearly every difference between them follows from a single architectural choice: hand-curated extensions or a source generator? This post walks through the differences, when each library is the better pick, and what actually happens — measured, not guessed — when you put both in one project.

Read More

VodiSoft

Turn your software idea into a revenue-generating product

For web, mobile, .NET, SaaS and integration projects, we can map the risks, timeline and fastest path to commercial value.

Get a Quote Meeting