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());
}
- Implement
IFmgLibHotReloadon the page (it declares a singlevoid Build()). - Call
this.InitializeHotReload()in the constructor. - 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:
- Your editor (or
dotnet watch) compiles the change and sends it into the running process through the .NET Hot Reload channel. - The runtime notifies the registered
MetadataUpdateHandlers. The library's handler is one of them. - The handler calls
Build()again, on the main thread, for every registered page. - Because
Build()assignsContentagain, 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 justFmgLibContentPage<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
Assignreferences in locals, never in fields. dotnet watch runis the most reliable channel; start every diagnosis from theupdate receivedline.- 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.