Skip to content

Microsoft.UI.Reactor (Reactor) ships handlers for the built-in WinUI control gallery, but the protocol that drives them is the same surface authors use to add their own native controls. The contract you wire up — an immutable element record, a descriptor or hand-coded handler, and a factory holder whose static constructor registers the handler with the global ControlRegistry — gives a custom control the same lifecycle, the same diff-and-write efficiency, the same pool reuse, and the same echo-safe two-way binding as Button, TextBox, and TabView. The factory holder pattern is trim-clean by construction: if the app never calls the factory, the handler and the underlying WinUI control type are unreachable and NativeAOT publishing drops both. This page is the cookbook for that flow. By the end you will have added a StarMeter element to a Reactor app, wired it to WinUI's RatingControl, and matched the built-in performance bar without writing a line of imperative interpreter code. Read the reference companion The Control Reconciler Protocol for the model the steps here plug into.

Extending Reactor with Native Controls

The audience is anyone wrapping a native Windows control — a third- party WinUI control, a custom Control subclass, or an existing WinUI control that the built-in catalog has not yet covered. The walk-through ports Microsoft.UI.Xaml.Controls.RatingControl end-to-end so the shape of every step is concrete; the patterns transfer to anything that descends from FrameworkElement. Cross-reference the built-in descriptors in src/Reactor/Core/V1Protocol/Descriptor/Descriptors/ as you adapt the example — they are the production examples of every shape this page mentions.

The four-step playbook

The whole flow is four steps, plus an optional fifth for control families that need a derived-type registration. The same four steps cover every leaf control in the built-in catalog; do them in the listed order — the factory holder in Step 3 must exist before Step 4 can use the element, because that holder's class-init is what registers the handler.

Step What you write Where it lives
1 An Element record subclass with one field per prop and one field per event callback, primary constructor internal so callers must reach it through the factory App code, next to the component that uses it
2 A ControlDescriptor<TElement, TControl> (or IElementHandler<TElement, TControl>) wiring each field to a WinUI property or event A static class in the same file or a sibling
3 A public static class factory holder whose static constructor calls ControlRegistry.Register<TElement, TControl>(static () => new MyHandler()) and whose Of(...) method is the sole construction path for the element Co-located with the element record
4 A component that calls MyControl.Of(...) like any other factory in the DSL Anywhere — usage shape is up to you

A fifth step — ControlRegistry.RegisterForDerivedTypes against a non-generic intermediate base — lets one registration catch every closed-T variant of a generic element family. It is what the typed templated-list ports use; you only need it if you are adding a similar family.

Caveat: Why a factory holder, not a manual Register call at startup? The static keyword on the lambda in Step 3 is mandatory, and the holder's class-init being the registration trigger is what makes the control trim-clean under NativeAOT publishing (see Step 6 — Wrap the constructor in a factory holder, then use it). If no component ever calls StarMeter.Of(...), the holder is unreachable; the trimmer follows that unreachability through the cctor to the handler class and the WinUI control type, and removes all three from the published binary. A manual reconciler.RegisterHandler<...> call at app bootstrap roots every custom control unconditionally and defeats that story — it is documented in The Control Reconciler Protocol as the explicit-override escape hatch for tests and host substitution, not the standard authoring path.

Step 1 — Define the Element record

// An Element subclass with one controlled prop (Value), three one-way
// props (MaxRating, Caption, IsClearEnabled), and one callback (OnValueChanged).
// Controlled props use Optional<T>: Unset means the WinUI control owns the value.
// Records give the reconciler value-equality for free — two StarMeterElement
// instances with identical fields compare equal and Update becomes a no-op.
//
// The primary constructor is `internal` (spec 048 §6 construction discipline):
// external callers cannot `new StarMeterElement(...)` directly, so the only
// reachable construction path is `StarMeter.Of(...)` below — whose class-init
// installs the global handler registration. Init properties stay `public` so
// `Of(...)` and its callers can configure the optional fields, and `with`
// expressions still work across the assembly boundary.
public sealed record StarMeterElement : Element
{
    public Optional<double> Value { get; init; } = Optional<double>.Unset;
    public int MaxRating { get; init; } = 5;
    public string? Caption { get; init; }
    public bool IsClearEnabled { get; init; } = true;
    public System.Action<double>? OnValueChanged { get; init; }

    internal StarMeterElement(Optional<double> value, System.Action<double>? onValueChanged = null)
    {
        Value = value;
        OnValueChanged = onValueChanged;
    }
}

The record encodes everything the element exposes — controlled props, one-way props, the optional caption, and the user's OnValueChanged callback. The choice of record matters: the reconciler diffs elements with value equality, so two StarMeterElement instances with identical fields compare equal and the descriptor's Update path becomes a no-op. Reach for record class when the element will be created millions of times per second (so the sealed-record allocation is the dominant cost); reach for record struct only after profiling shows the heap pressure matters — most custom controls are well-served by the default sealed-record shape.

The primary constructor is internal on purpose: it closes off new StarMeterElement(...) from outside the assembly that owns the element, leaving StarMeter.Of(...) (Step 6) as the sole external construction path. That is the construction discipline spec 048 §6 requires for the factory-holder trim story to hold — if external callers could bypass Of, the holder's class- init would not be guaranteed to run and the global registration could be missing when the element reached the reconciler. Init properties stay public so Of(...) (and with expressions for downstream authors) can configure the optional fields across the assembly boundary.

The fields below are the only thing your component author touches. Every named property maps to one descriptor entry in Step 2.

Field Kind Why
Value controlled Optional<double> HasValue means Reactor force-asserts the value; Unset means the WinUI control owns it.
MaxRating one-way int Driven by the element, never edited on the WinUI side.
Caption optional one-way string? Demo uses an explicit skip predicate; DP-backed fallbacks should prefer Optional<T> + dp:.
IsClearEnabled one-way bool Same as MaxRating — declarative knob.
OnValueChanged callback Action<double>? Subscribes only when non-null; the descriptor gates the trampoline on it.

Inherit from Element (not FrameworkElement, not Control) — this is the Reactor record, not the WinUI control. The built-in fluent modifier chain (.Width(...), .Margin(...), .Foreground(...)) applies to the built-in element types it was authored against; a custom element does not get those modifiers for free. Authors who want chainable modifiers on their own element add an Action<TControl>[] Setters field and an extension-method namespace that appends to it, then point the descriptor's GetSetters selector at the field. The Modifier System page walks through the chain shape; keeping the demo focused, the cookbook below omits it.

Step 2 — Choose a shape (descriptor vs hand-coded)

Reactor's handler protocol has two surfaces — declarative (ControlDescriptor) and imperative (IElementHandler) — that land on the same dispatch table. New controls should default to a descriptor; reach for a hand-coded handler only when the descriptor escape hatches (.Imperative, .HandCoded…) still cannot express the diff.

Use a descriptor when Reach for a hand-coded handler when
Props map 1:1 to WinUI properties, or jointly through a tuple get Mount has irreducible imperative logic (template-part lookups via OnApplyTemplate, multi-control composition)
Events follow subscribe(handler) ↔ readBack(control) Events route through non-CLR shapes (raw routed events, native callbacks)
The control fits one of the standard ChildrenStrategy shapes The control has a children-realization model the strategy set does not cover (custom virtualization, mixed item types per slot)
You want the next reader to skim the spec, not the code You are wrapping a control whose contract the framework cannot yet express declaratively, and adding a new entry shape would not pay back

Every built-in port in src/Reactor/Core/V1Protocol/Descriptor/Descriptors/ is a descriptor. The few hand-coded IElementHandler implementations in src/Reactor/Core/V1Protocol/Handlers/ are the ones whose diff shape genuinely does not fit a declarative entry list — read them as the bar for "complicated enough to write by hand."

Step 3 — Wire props

public static class StarMeterDescriptor
{
    public static readonly ControlDescriptor<StarMeterElement, WinUI.RatingControl> Descriptor =
        new ControlDescriptor<StarMeterElement, WinUI.RatingControl>
        {
            // Leaf control — no children. (See ChildrenStrategy survey for
            // the other shapes: SingleContent, Panel, NamedSlots, ItemsHost…)
            Children = new None<StarMeterElement, WinUI.RatingControl>(),
        }
        // OneWay props: written on Mount, diff-and-written on Update.
        .OneWay(
            get: static e => e.MaxRating,
            set: static (c, v) => c.MaxRating = v)
        .OneWay(
            get: static e => e.IsClearEnabled,
            set: static (c, v) => c.IsClearEnabled = v)
        // OneWayConditional skips the write when the predicate is false —
        // leaves Caption at the control's default for elements that didn't
        // supply one, rather than forcing it to null and losing a style.
        .OneWayConditional(
            get:         static e => e.Caption,
            set:         static (c, v) => c.Caption = v!,
            shouldWrite: static e => e.Caption is not null)
        // Controlled is the two-way binding shape. Its get lambda returns
        // Optional<double>: Unset skips framework writes so the control owns
        // the value; HasValue force-asserts on Mount and Update with echo
        // suppression, then forwards user input through OnValueChanged.
        // Subscription is gated on the callback being non-null.
        .Controlled<double, object>(
            get:         static e => e.Value,
            set:         static (c, v) => c.Value = v,
            subscribe:   static (fe, h) => ((WinUI.RatingControl)fe).ValueChanged += (s, e) => h(s, e!),
            unsubscribe: static (fe, h) => { /* trampoline anchored for control lifetime */ },
            callback:    static e => e.OnValueChanged,
            readBack:    static c => c.Value);

    // The thin `new()`-able handler subclass that the `static` lambda in
    // StarMeter's cctor instantiates. Subclassing DescriptorHandler keeps
    // the descriptor accessible *only* through this handler — the trimmer
    // can drop both if the StarMeter factory is never called.
    internal sealed class Handler : DescriptorHandler<StarMeterElement, WinUI.RatingControl>
    {
        public Handler() : base(StarMeterDescriptor.Descriptor) { }
    }
}

Each fluent builder call adds one PropEntry to the descriptor. The interpreter iterates them in declaration order during Mount and Update. The descriptor above uses three of the common shapes:

.OneWay(get, set)MaxRating and IsClearEnabled. The interpreter writes the value at Mount; on Update it compares the old element's value against the new and writes only when the values differ. Use this for always-write props with no callback: the element is authoritative and there is no user edit channel to echo. The default comparer is EqualityComparer<TValue>.Default; pass an explicit comparer to the optional comparer parameter for floating-point tolerance or custom-type equality.

.OneWay(get, set, dp:) — the DP-backed fallback channel. When the get lambda returns Optional<T>, HasValue writes through set and Unset calls ClearValue(dp), releasing the local value so WinUI's style / template / inherited / default precedence chain can win. Use this when the authoring question is one-way with WinUI fallback (for example, a themeable brush). If there is no DP or the predicate is not a simple set-vs-unset choice, keep .OneWayConditional(get, set, shouldWrite) as the explicit skip-write shape.

.InitialOnly(get, set) — the mount-only channel. It writes once at Mount and never on Update, the same role React's defaultValue plays for uncontrolled inputs. Use it for props that seed a control and then must hand authority to WinUI forever.

.Controlled<TValue, TArgs>(...)Value. Controlled props always use an Optional<TValue> element field and an Optional-returning get: lambda. Unset returns early on Mount and Update, meaning the WinUI control owns the value; HasValue force-asserts the element's value (bare on Mount, echo-suppressed on Update) and user interaction round-trips through the callback. This is Reactor's C# equivalent of React's controlled-input split: React can tell <input> from <input value={x}> because JSX preserves prop omission, while C# records cannot distinguish an omitted property from a property set to its default. Optional<T> supplies that missing bit.

Decision tree for descriptor authors:

Intent Element prop type Builder
Controlled with user authority Optional<T> .Controlled(...) / .HandCodedControlled(...)
One-way with WinUI fallback Optional<T> .OneWay(get, set, dp: SomeControl.SomeProperty)
Mount-only seed T .InitialOnly(get, set)
Always write, no callback T .OneWay(get, set)
Reference to another realized element ElementRef<TTarget>? .Reference<TTarget>(get, set)
Ordered references to several elements IReadOnlyList<ElementRef<TTarget>>? .ReferenceList<TTarget>(get, apply)

Reference properties

Use reference entries when a native control property points at another realized FrameworkElement: TeachingTip.Target, relationship properties such as automation LabeledBy, or custom owner/source slots on a control. The element record stores an ElementRef<TTarget> (or an ordered list of them). The descriptor declares the edge:

descriptor.Reference<FrameworkElement>(
    get: e => e.Target,
    set: (c, target) => c.Target = target);

descriptor.ReferenceList<FrameworkElement>(
    get: e => e.Related,
    apply: (c, targets) =>
    {
        c.Related.Clear();
        foreach (var target in targets)
            c.Related.Add(target);
    });

.Reference writes the current target on mount, subscribes to the cell, and rewrites the property when the target mounts later or unmounts. .ReferenceList rebuilds the target list in author declaration order and omits unresolved cells. Hand-coded handlers use binding.Reference / binding.ReferenceList for the same edge when no descriptor entry can express the control. D1 rule: do not read ref.Current inside a handler and assign it to a reference property; that is only a snapshot and breaks late binding and clear-on-unmount. REACTOR_REF_001 steers that anti-pattern back to a reactive edge.

Caveat: For reference-type optionals, the implicit conversion is deliberate but easy to misread: with { Background = null } for an Optional<Brush> property becomes Optional.Of(null) — an explicit force-assert of nullnot Optional<Brush>.Unset. Write Optional<Brush>.Unset when the control should own the value.

The subscribe lambda's shape is (FrameworkElement, EventHandler<TArgs>) → void; the engine boxes the control identity through FrameworkElement to keep the entry generic across closed-T tuples. TArgs is whatever the WinUI event carries — for RatingControl.ValueChanged the args type is object because WinUI authors the event with TypedEventHandler<RatingControl, object>. Subscription is gated on callback returning non-null — if the element doesn't carry OnValueChanged, no trampoline is wired and the dispatch cost for that prop stays at zero.

Caveat: The unsubscribe lambda is intentionally a no-op body — descriptor trampolines anchor to the control's lifetime, not the element's. The control returns to the pool, the pool reset contract clears the trampoline slot on the typed event-payload box, and the next Mount finds an empty slot and re-subscribes. Writing a real unsubscribe into the lambda double-frees the subscription when the control pools and is the most common source of "the event stops firing after a re-render" bugs. If your control genuinely needs explicit unsubscribe on unmount, override IElementHandler.Unmount and tear down there.

Step 4 — Wire events (the non-DP case)

StarMeterElement.Value is bound to a property and an event together, so it uses the .Controlled shape above. Events that do not round-trip a DP — Button.Click, Image.ImageOpened, TextBox.SelectionChanged — use a sibling builder:

Builder Use when
.HandCodedEvent<TPayload, TDelegate>(subscribe, callbackPresent, trampoline, slotIsNull, setSlot) Fire-and-forget event with no associated DP. The trampoline is a static delegate that closes over no per-instance state; per-control state goes on the TPayload slot.
binding.OnCustomEvent<TArgs>(subscribe, unsubscribe, handler) inside a hand-coded handler When you have already chosen the hand-coded shape and want the simpler closure-based trampoline. Allocates a closure per Mount instead of reusing a static delegate.

The ButtonDescriptor source (in src/Reactor/Core/V1Protocol/Descriptor/Descriptors/ButtonDescriptor.cs) is the canonical example of .HandCodedEvent — including the short-circuit for IsDisabledFocusable and the Reconciler.GetElementTag tag refresh.

Step 5 — Declare a child strategy

StarMeterElement is a leaf, so the descriptor declares Children = new None<…>(). The dispatch surface is the same as the ChildrenStrategy survey; the right strategy is whichever matches your WinUI control's structural contract:

Your WinUI control's child shape Strategy
No children None
One Content / Child slot SingleContent(GetChild, SetChild) { GetCurrentChild = … }
Children collection on a Panel Panel(GetChildren, GetCollection)
Named slots (Header, Content, Footer) NamedSlots([NamedSlot("Header", …), …])
Flat items collection (Items) of values or pre-built elements ItemsHost(GetItems, GetCollection)
Typed templated list with keyed reconcile TemplatedItems<TItem, TElement, TControl>(GetItems, KeySelector, BuildItemView)
Hierarchical tree TreeChildren(GetNodes)
Tabs / pivots with per-item containers TabItemsHost(GetItems, GetCollection, GetContent, CreateContainer)

The standard strategies cover everything in the built-in catalog. The Imperative<TElement, TControl> escape hatch exists for the case where none of them fit — for example, a control whose children live on three different non-collection properties that must reconcile together. Reach for it last; you give up the engine's keyed reconcile and lose descendant component state across re-renders that touch the imperative slot.

Step 6 — Wrap the constructor in a factory holder, then use it

// Spec 048 §6 Pattern A — the factory holder *is* the registration trigger.
// The static cctor runs the first time any member of `StarMeter` is touched
// (CLR-guaranteed precise-init), which means the global ControlRegistry
// entry is in place before the first Of() call returns its element.
//
// The `static` keyword on the lambda is MANDATORY (not stylistic): it
// guarantees the delegate is cached in a static field (one allocation,
// ever) and captures nothing. A non-static lambda compiles but allocates
// a closure per Register call AND defeats the trimmer's ability to follow
// the holder→handler→control chain. The static lambda is what makes
// Pattern A trim-clean.
public static class StarMeter
{
    static StarMeter() =>
        ControlRegistry.Register<StarMeterElement, WinUI.RatingControl>(
            static () => new StarMeterDescriptor.Handler());

    // Sole construction path for StarMeterElement (spec §6 construction
    // discipline). Calling Of() guarantees the handler is registered before
    // the returned element is mounted — the cctor above runs before any
    // member of this type, including Of, can be invoked.
    public static StarMeterElement Of(
        double value,
        System.Action<double>? onValueChanged = null,
        int maxRating = 5,
        string? caption = null,
        bool isClearEnabled = true) =>
        Of(value, onValueChanged, maxRating, caption, isClearEnabled);

    public static StarMeterElement Of(
        Optional<double> value,
        System.Action<double>? onValueChanged = null,
        int maxRating = 5,
        string? caption = null,
        bool isClearEnabled = true) =>
        new(value, onValueChanged)
        {
            MaxRating = maxRating,
            Caption = caption,
            IsClearEnabled = isClearEnabled,
        };
}

The StarMeter holder is the bridge between your descriptor and the runtime. The CLR's precise-initialization rules guarantee its static constructor runs exactly once, immediately before the first member access — which means the global ControlRegistry.Register call lands before StarMeter.Of(...) can return. No app bootstrap code is involved; the registration is co-located with the only construction path for the element. Multiple components calling StarMeter.Of(...) from different threads are safe — the CLR serializes class init.

The static keyword on the lambda passed to ControlRegistry.Register is mandatory, not stylistic:

  • A static lambda is cached in a compiler-generated static field — one delegate allocation, ever, no matter how many times the cctor runs (which is one).
  • A static lambda captures nothing (a capture is a compile error), so the trimmer can prove the handler factory does not hold a reference to anything outside MyHandler itself. That is what lets the trimmer drop the entire holder → handler → control chain when StarMeter is unreachable.
  • A non-static lambda compiles, allocates a closure object per Register call, and roots whatever it captures (typically this) — which on a static cctor is nothing useful, but the trimmer treats the closure as a generic reference that may hold arbitrary types. See Common Mistakes — Passing a non-static lambda to ControlRegistry.Register.
class ExtendingApp : Component
{
    public override Element Render()
    {
        var (rating, setRating) = UseState(3.5);

        return VStack(16,
            TextBlock("StarMeter — custom element wrapping WinUI RatingControl")
                .FontSize(14).SemiBold(),

            // StarMeter.Of(...) is the sole construction path: it returns a
            // StarMeterElement AND ensures (via its cctor) that the global
            // ControlRegistry has the handler. No reconciler.RegisterHandler
            // call lives anywhere in this app.
            StarMeter.Of(rating, setRating, caption: "Rate this page"),

            TextBlock($"current rating: {rating:0.0}"),

            HStack(8,
                Button("Reset", () => setRating(0)),
                Button("5 stars", () => setRating(5)))
        ).Padding(20);
    }
}

Custom StarMeter element rendered alongside built-in Reactor controls

That is the entire path from setRating(5) to a frame. The component calls StarMeter.Of(rating, setRating, ...); the holder's class-init has already run, so the global ControlRegistry carries the entry for StarMeterElement; the reconciler walks the new element tree, the registered descriptor handler is invoked for StarMeterElement, the interpreter diffs against the previous element (only Value changed), writes through WriteSuppressed, the trampoline drains the suppress counter on the next echo, and the loop ends with the control at 5 stars. The rest of the app's controls dispatch through the built-in handlers in lockstep.

Prove your control trims. The repo ships a tiny NativeAOT proof harness at tests/aot_trim_proof/ that publishes a Reactor app whose Render() references only TextBlock and Button, then asserts that the handler class names and element record names of every other built-in control are absent from the published binary. Copy the same pattern for your control: publish your app with dotnet publish -p:PublishAot=true, then grep the resulting binary for your custom handler class name. If StarMeter.Of(...) is reachable, the name appears; if no component references it, the name is gone. That round-trip is the only authoritative test that Pattern A is wired correctly.

Patterns

A few recurring shapes turn up across most custom-control ports:

Match a built-in's perf bar without thinking about it. Use a descriptor; let RentControl pool the WinUI control; let Controlled suppress its own echoes. The interpreter's per-entry diff is a single equality check against the previous element's value — unchanged props pay one comparison and zero writes. Pool reuse, echo suppression, and static trampolines are defaults of the protocol, not opt-ins.

Bundle related fields into one prop entry. A control that exposes (Color, IsOn) → Background is one entry, not two — the get returns a tuple and the set reads both fields. The diff fires exactly when either input changes. This is how the LED indicator in the Control Reconciler Protocol reference collapses two element fields into one WinUI write.

Layer in a Reactor-friendly default for an awkward control. Many WinUI controls require an initial property write to "behave" (TextBox.PlaceholderText, RatingControl.MaxRating). Use .InitialOnly(get, set) to seed those once at Mount without paying the diff-and-write cost on Update. The interpreter ignores InitialOnly entries on the Update path.

Wrap a third-party control whose API is unstable. Put the descriptor in a sealed static class, mark every reference to the third-party type internal, and re-export an Element-typed factory. Your component authors depend on the element, not the control. When the third-party control breaks compatibility, you update one file; every component keeps compiling.

Pre-flight the perf bar with the bench harness. The Reactor repo includes a perf_bench project (tests/perf_bench/) that exercises descriptors against the M1 / M2 / M5 / M7 / M10 micro-benches the spec uses to validate the descriptor model. Drop your descriptor into the harness and run the relevant bench before locking your port in.

Common Mistakes

Subscribing in Update instead of Mount. A descriptor's Controlled entry already gates subscription on the mount-then-subscribe ordering — the interpreter calls EnsureSubscribed once after the Mount loop and again on each Update to catch null→non-null callback transitions. If you are hand-coding a handler and forget to centralize subscription in Mount, every re-render double-subscribes the event.

Forgetting WriteSuppressed in a hand-coded Update. A controlled prop write from Update echoes through the trampoline back into the user's state setter. The descriptor's Controlled entry wraps the write for you; a hand-coded handler must mirror it with binding.WriteSuppressed(() => ctrl.Value = newValue). Forgetting it is the single most common cause of "the value snaps back when I edit it" bugs.

Stashing per-control state on the descriptor / handler. The descriptor and the handler instance are registered once per host. Per-control state needs to live on the control — either on the attached state DP (use Reconciler.SetElementTag / GetElementTag) or on the typed event payload box (use Reconciler.GetOrCreateControlEventPayload<TPayload>). The pool reset contract clears both on ReturnControl; descriptor-level state survives pool rent and corrupts the next mount.

Passing a non-static lambda to ControlRegistry.Register. The static keyword on the factory lambda is mandatory (see Step 6). Writing ControlRegistry.Register<MyElement, MyControl>(() => new MyHandler()) without the static modifier compiles, but allocates a closure object per Register call and defeats the trimmer's ability to drop the handler/control chain when the factory holder is unreachable. The compiler will accept either shape; reviewers and analyzers are your only guard. (Issue #486 tracks adding the analyzer.) Always write static () => new MyHandler().

Bypassing the factory with new MyElement(...). The whole reason the element's primary constructor is internal is to make new StarMeterElement(...) from outside the assembly a compile error. If you also expose the element record from a library, do not re-export a public constructor or a parameter-forwarding public StarMeterElement Create(...) helper that calls the internal ctor — both bypass the holder's class-init and the first time your library's element reaches an unregistered reconciler the mount will throw. StarMeter.Of(...) (which triggers the cctor and returns the element) is the only externally-reachable construction path that's safe.

Registering on a non-global reconciler when you meant the global one. Reconciler.RegisterHandler<...> is the per-host escape hatch documented in The Control Reconciler Protocol; it is meant for tests substituting a fake handler, not for shipping custom controls. If your app has multiple ReactorHost instances and your control is registered through Pattern A on the global ControlRegistry, every host sees it automatically — the four-step dispatch precedence ends with the global registry, so no per-host call is needed.

Capturing the element in a static trampoline. The static trampoline pattern works because it reads the live element on every fire. Capturing the element in the static closure is a contradiction in terms — there is no closure on a static — but the equivalent mistake on a hand-coded trampoline that closes over the element instance does exactly that, and the trampoline observes the element that existed at Mount forever.

Tips

Start from the closest built-in descriptor. Copy the most similar descriptor in src/Reactor/Core/V1Protocol/Descriptor/Descriptors/, strip what does not apply, and adapt. The built-ins encode the production patterns for every shape you are likely to need — including the coercion edge cases (Slider.Min, NumberBox.Max) and the multi-event control patterns (TextBox's Text + SelectionChanged).

Keep the element record minimal. Every field is a public contract — once consumers depend on it, you cannot drop or rename it without churn. Add fields as components need them, not preemptively.

Test the round trip with the existing fixture harness. Add a fixture in tests/Reactor.AppTests.Host/SelfTest/Fixtures/ that drives your control via an automation peer (IInvokeProvider/IValueProvider) and asserts the callback fires the right number of times for the right values. The pattern is established and the fixture runner gives you a stable stable cross- process test rig for free.

Document the perf shape inline. If your control has non-obvious performance characteristics — pool opt-out, custom comparer for floating-point tolerance, deliberately imperative entry — capture the reason in a comment on the descriptor field, not on the calling component. The descriptor is where future maintainers will look.

Read the Control Reconciler Protocol reference once end-to-end. The six body sections above are the recipe; the protocol page is the specification it implements. The two pages together are the complete contract — bookmark both.

Keep devtools-adjacent hooks in the optional package. Control helpers that only serve the --devtools MCP/preview surface belong in Microsoft.UI.Reactor.Devtools, not core Microsoft.UI.Reactor. That keeps retail apps from resolving or shipping devtools implementation IL unless they add the optional package and enable Reactor.DevtoolsSupport.

Next Steps

  • The Control Reconciler Protocol — the model the steps on this page plug into.
  • Reconciliation — what happens before the registered handler is invoked, and how Mount vs Update vs Unmount get chosen.
  • Element Pool — what RentControl actually does, and how the per-type pool keeps Mount allocation-free.
  • Modifier System — how the Setters chain a descriptor applies after its own prop loop works.
  • Hooks Internals — how state changes turn into Render calls, which is the upstream half of every loop the protocol on this page is the downstream half of.