MauiReactor Component Pages (MVU)
Nalu navigation can drive MauiReactor stateful
components as first-class pages: no Page subclass, no page model, no BindingContext —
the component is the navigation destination and the lifecycle target. Typed navigations,
guards, lifecycle events, intents, tab-stack preservation and (under the
Scaffold) gesture-driven back all work identically to the MVVM and
view-only modes.
Nalu deliberately ships no MauiReactor package: the bridge is a small class you paste into
your app, built on MauiReactor's public TemplateHost integration primitive. The seam it
plugs into (IComponentPageFactory) is framework-agnostic — the same two-member contract
bridges other component frameworks too.
Quick Start
1. Paste the bridge and enable it
With Reactor.Maui installed, add this class to your app (this exact code is exercised by
Nalu's test suite):
using MauiReactor;
using MauiPage = Microsoft.Maui.Controls.Page;
internal sealed class MauiReactorComponentPageFactory : IComponentPageFactory
{
public IComponentPageHandle CreatePage(object component)
{
if (component is not VisualNode visualNode)
{
throw new InvalidOperationException($"{component.GetType().FullName} must derive from MauiReactor.Component to be used as a component-based page.");
}
var host = new TemplateHost(visualNode);
if (host.NativeElement is not MauiPage page)
{
host.Stop();
throw new InvalidOperationException($"{component.GetType().FullName} must render a Page-derived root (e.g. ContentPage) to be used as a navigation page.");
}
return new Handle(host, page, component);
}
private sealed class Handle(TemplateHost host, MauiPage page, object component) : IComponentPageHandle
{
public MauiPage Page => page;
public object LifecycleTarget => component;
public void Dispose() => host.Stop();
}
}
Then register it — one line on each builder:
builder
.UseMauiApp<App>()
.UseNaluNavigation<App>(nav => nav
.AddPages() // source-generated, includes opted-in components (below)
.UseComponentPageFactory<MauiReactorComponentPageFactory>())
.UseMauiReactor() // MauiReactor's own init (Component.Services etc.)
.UseNaluScaffold();
2. Write components, mark the page-rendering ones
A navigable component is a regular MauiReactor component whose Render() returns a
Page-rooted tree (e.g. ContentPage(...)). Decorate it with [AutoNavigationPage] and
the source-generated AddPages() registers it:
class CounterState
{
public int Count { get; set; }
}
[AutoNavigationPage]
partial class CounterPage(INavigationService navigation) : Component<CounterState>
{
public override VisualNode Render()
=> ContentPage(
VStack(
Label($"Count: {State.Count}"),
Button("Increment").OnClicked(() => SetState(s => s.Count++)),
Button("Details").OnClicked(() => navigation.GoToAsync(Nav.Push<DetailPage>(State.Count)))
).Center()
).Title("Counter");
}
On non-Page classes the attribute is an opt-in (nothing else about a component reveals
that it renders a page — most components are view fragments, and those must never be
registered), while on ContentPages it keeps its usual opt-out role
(Enabled = false). Components you prefer to register by hand use the same model-less
overload as view-only pages:
nav.AddPage<CounterPage>()
.AddPage<DetailPage>();
3. Navigate by component type
The standard API accepts component types wherever it accepts page or page-model types
(Nav is the usual global using Nav = Nalu.Navigation; alias):
await _navigationService.GoToAsync(Nav.Push<DetailPage>());
await _navigationService.GoToAsync(Nav.Pop());
await _navigationService.GoToAsync(Nav.Root<CounterPage>());
And a component is a valid Scaffold root:
new ScaffoldRoot { Title = "Counter", PageType = typeof(CounterPage) }
Inside a component, reach the engine through INavigationService — constructor injection
works because Nalu creates the component inside the page's own DI scope (scoped services
like INavigationServiceProvider included). Prefer it over MauiReactor's Navigation
property: with Nalu hosting the app, raw INavigation pushes are rejected by design (pops
are fine), because untyped page instances bypass the engine.
4. Lifecycle, guards and intents live on the component
Implement the same interfaces a page model would — directly on the component. Typed intents included:
class DetailState
{
public int ItemId { get; set; }
}
[AutoNavigationPage]
partial class DetailPage : Component<DetailState>, IEnteringAware<int>, ILeavingGuard
{
public ValueTask OnEnteringAsync(int itemId)
{
SetState(s => s.ItemId = itemId); // re-renders into the SAME native page
return default;
}
public async ValueTask<bool> CanLeaveAsync()
=> !_hasUnsavedChanges
|| await ConfirmDiscardAsync();
public override VisualNode Render() => ContentPage( /* ... */ );
}
Everything in Lifecycle Events and
Intents applies unchanged, with "page model" read as "component".
ILeavingGuard is honored on every leave path (programmatic pops, back button, Android
system/predictive back, iOS edge swipe), and intents implemented by registered components
feed the generated AddIntents(), so State Restoration replays them
after an app restart.
Who receives lifecycle events?
For component pages the rule is simpler than the
view-only precedence: the
component is the lifecycle target, unconditionally. Nalu never assigns the native page's
BindingContext (propagating a context through a component-rendered tree would be pure
overhead for an MVU framework that doesn't use bindings), and even an explicitly assigned one
would not steal the lifecycle.
One consequence worth knowing: the Scaffold nav bar's PageBindingContext stays null for
component pages. That's coherent with MVU — drive the nav bar by setting the Scaffold
attached properties with concrete values from Render(); re-renders update them.
How it works
- Creation: on navigation, Nalu resolves the component from the page's fresh DI scope and
hands it to your registered factory, which mounts it through MauiReactor's
TemplateHost. ThePagethe component renders becomes the navigation page — pushed, transitioned and tracked like any other. - Re-renders:
SetStateupdates flow into that same page instance; the navigation stack never sees a page swap. - Teardown: when the page leaves the stack, Nalu unmounts the component tree, then disposes the scope and disconnects handlers — the exact lifecycle pages and page models get. The leak detector watches component pages too.
Notes
- The root must be a Page. A component rendering a plain view is rejected at navigation
time with a descriptive error — wrap the content in
ContentPage(...). - MauiReactor hot reload (the
dotnet-maui-reactorconsole) does not currently refresh Nalu-hosted component pages: MauiReactor's assembly-swap pipeline is internal to its own hosts. State-driven re-renders and regular .NET hot reload of method bodies are unaffected. - Mixing modes works per destination type: MVVM pages, view-only pages and component pages coexist on the same stack.
- Other component frameworks (Comet, BlazorBindings.Maui, …): implement
IComponentPageFactorythe same way — turn a component instance into anIComponentPageHandleexposing the renderedPageand the lifecycle target — and register it vianav.UseComponentPageFactory<TFactory>(); the engine takes care of everything else. Note the contract is synchronous (the page is pushed immediately): an async-rendering framework should materialize into a shell page it fills.