Microsoft.UI.Reactor (Reactor)'s render loop has a small set of invariants. Most are enforced by analyzers, so a violation surfaces at build time with a specific code; the rest are conventions that the framework expects. This page lists them, names the analyzer where one exists, and shows the before/after pair so the catch is visible.
The five core rules:
- Hook order is stable across renders.
- Render functions are pure.
- Lists need stable keys.
- Setters returned by hooks are stable; deps must be stable too.
- Theme-aware modifiers want a token, not a literal.
// REACTOR_HOOKS_001 — hooks must run unconditionally on every render.
// Wrapping a hook in `if` shifts the hook indices when the branch flips,
// and the next render reads slot N expecting `UseEffect` but finds
// `UseState`. The HookOrderException it raises is loud, but the bug
// can ship if the conditional is rarely true.
class HookOrderBad : Component
{
public bool ShouldCount;
public override Element Render()
{
if (ShouldCount)
{
var (count, _) = UseState(0); // REACTOR_HOOKS_001
return TextBlock($"Count: {count}");
}
return TextBlock("No counter.");
}
}| Rule | Analyzer | Page |
|---|---|---|
| Hook order is stable | REACTOR_HOOKS_001 |
Hooks |
| Hooks called from Render only | REACTOR_HOOKS_005 |
Hooks |
| Deps are stable | REACTOR_HOOKS_004 |
Hooks |
| Render is pure | (convention) | Components |
| Lists need keys | (convention) | Collections |
| Theme tokens not literals | REACTOR_THEME_001 |
Theming Tokens |
| Lightweight styling | REACTOR_THEME_002 |
Styling |
| Accessible names | REACTOR_A11Y_001..003 |
Accessibility |
Each rule below covers one row in the index with the before / after shape and the analyzer's catch.
Hooks store their state at a slot index in the component's hook list.
The reconciler walks the list by ordinal on every render — so if
UseState was at slot 0 on render 1 and slot 1 on render 2, the
state migrates to the wrong slot and the value silently corrupts.
The rule: call every hook unconditionally at the top of Render(),
in the same order, every time. No if, no for, no try/catch
around a hook call, no early return.
Analyzer: REACTOR_HOOKS_001 (conditional hook call),
REACTOR_HOOKS_005 (hook called outside a Render() override or a
Use* helper).
Before:
// REACTOR_HOOKS_001 — hooks must run unconditionally on every render.
// Wrapping a hook in `if` shifts the hook indices when the branch flips,
// and the next render reads slot N expecting `UseEffect` but finds
// `UseState`. The HookOrderException it raises is loud, but the bug
// can ship if the conditional is rarely true.
class HookOrderBad : Component
{
public bool ShouldCount;
public override Element Render()
{
if (ShouldCount)
{
var (count, _) = UseState(0); // REACTOR_HOOKS_001
return TextBlock($"Count: {count}");
}
return TextBlock("No counter.");
}
}After:
class HookOrderGood : Component
{
public bool ShouldCount;
public override Element Render()
{
// Hook always runs; the conditional moves into the render output.
var (count, _) = UseState(0);
return ShouldCount
? TextBlock($"Count: {count}")
: TextBlock("No counter.");
}
}The hook runs unconditionally; the branch moves into the element tree
returned by Render(). Same UI, stable hook order.
Full coverage on Hooks.
Render() runs every time the component re-renders — which can be
many times per second under animation or input. A side effect inside
Render() (writing to a static counter, calling a logger, opening a
file) fires on each render, including dev-mode double-renders that
catch the bug. The right place for a side effect is inside UseEffect,
which runs once per render commit after the tree is materialized.
The rule: Render reads state and returns elements. It does not
mutate, call I/O, or fire telemetry. Side effects go in UseEffect.
Before:
// Render must be pure. Side effects (file I/O, mutation of static state,
// timers) belong inside UseEffect, which runs after the render commits.
// A logger call inside Render mounts will fire on every re-render,
// including ones triggered by the debugger — and it makes snapshot tests
// flaky because the rendered output now depends on a side effect.
static class TelemetryBad
{
public static int CardRenders;
}
class CardBad : Component
{
public override Element Render()
{
TelemetryBad.CardRenders++; // side effect in Render
return TextBlock("Card");
}
}After:
static class Telemetry
{
public static int CardRenders;
}
class CardGood : Component
{
public override Element Render()
{
UseEffect(() =>
{
Telemetry.CardRenders++;
return () => { };
});
return TextBlock("Card");
}
}The counter still increments on every render, but it does so inside
the effect — which means a snapshot test of CardGood doesn't have a
side-effect-on-render bug; the effect fires after the test inspects
the tree. Full coverage on Effects and
Components.
When the items in a ForEach / ListView / Select(...).ToArray()
reorder, the reconciler diffs the old tree against the new one.
Without keys, it diffs positionally — and a row that swapped places
gets the previous row's local state (focus, scroll offset, in-flight
edit). With .WithKey(id), the reconciler matches by key and moves
the right state to the right row.
The rule: every element produced inside a list-like construct gets
a .WithKey(stableId) whose value persists across re-renders. The id
must be the record's primary key, not the array index.
Before:
// A list reorder without keys forces the reconciler to walk both lists in
// order and reuse slot 0 for whatever new item lands first. Local state
// (focus, scroll position, in-flight edits) gets attached to the wrong
// row. WithKey on each child binds state to identity rather than slot.
class TodoListBad : Component
{
public TodoItem[] Items = System.Array.Empty<TodoItem>();
public override Element Render() => VStack(4,
Items.Select(i =>
// No .WithKey — reorder is destructive.
TextBox(i.Title, _ => { }, header: i.Id.ToString())
).ToArray()
);
}
public record TodoItem(int Id, string Title);After:
class TodoListGood : Component
{
public TodoItem[] Items = System.Array.Empty<TodoItem>();
public override Element Render() => VStack(4,
Items.Select(i =>
TextBox(i.Title, _ => { }, header: i.Id.ToString())
.WithKey(i.Id.ToString()) // stable identity
).ToArray()
);
}Full coverage on Collections and Reconciliation.
The Action<T> setter returned by UseState, UseReducer, and
UsePersisted keeps the same delegate identity across renders. You
can safely close over it inside a UseEffect cleanup, a captured
event handler, or a Task — the captured reference is the live
setter.
The same can't be said of dependency arrays. A freshly-allocated
array or a freshly-allocated record passed as deps differs by
reference on every render, so the effect re-fires every time:
// Wrong:
UseEffect(Setup, new[] { name, version }); // freshly-allocated array
// REACTOR_HOOKS_004 flags this.// Right:
UseEffect(Setup, name, version); // params overload — items
// compared by value.The rule: pass deps via the params overload of UseEffect/
UseMemo/UseCallback, not as a freshly-allocated array. The
analyzer catches the common shape; for cases the analyzer can't see
(a Tuple<...> allocated inline), assign the deps to a local first.
Analyzer: REACTOR_HOOKS_004 (unstable deps),
REACTOR_HOOKS_007 (UseMemoCells builder missing a captured
dependency).
Full coverage on Hooks.
.Background, .Foreground, and .WithBorder take a brush. A hex
literal works for the demo but breaks the moment the user flips
themes — the literal is locked to the value you typed, so the
brand-blue button stays blue on a now-dark background and the
contrast collapses.
The rule: pass a Theme.* token (or Theme.Ref("CustomKey") for
a custom XAML resource key) to any theme-aware modifier. Reserve hex
literals for the rare case where the color is intentionally
theme-invariant (a brand mark, a print preview) — and leave a comment
saying so.
Analyzer: REACTOR_THEME_001 (hard-coded color string on a
theme-aware modifier).
// Don't:
Button("Save", () => { }).Background("#0066CC"); // REACTOR_THEME_001// Do:
Button("Save", () => { }).Background(Theme.Accent);Full coverage on Theming Tokens.
| Rule | Analyzer | Where the page lives |
|---|---|---|
| Hook order is stable | REACTOR_HOOKS_001 |
Hooks |
Hooks called from Render only |
REACTOR_HOOKS_005 |
Hooks |
| Deps must be stable | REACTOR_HOOKS_004 |
Hooks |
| UseResource fetcher is idempotent | REACTOR_HOOKS_006 |
Async Resources |
| UseMemoCells builder must close over deps | REACTOR_HOOKS_007 |
Hooks |
| Render must be pure | (convention — no analyzer) | Components, Effects |
| Lists need keys | (convention — no analyzer) | Collections, Reconciliation |
| Theme-aware modifiers want a token | REACTOR_THEME_001 |
Theming Tokens |
| Lightweight styling, not implicit resources | REACTOR_THEME_002 |
Styling |
RequestedTheme is a render input, not a setter |
REACTOR_THEME_003 |
Styling |
| XML docs on public APIs | REACTOR_DOC_001 |
(framework code) |
<see cref="..."/> resolves |
REACTOR_DOC_002 |
(framework code) |
| Accessible name on interactive elements | REACTOR_A11Y_001..003 |
Accessibility |
Treat analyzer warnings as build errors in CI. Every rule with an analyzer has a known false-positive rate near zero; the cost of a suppression is small, the cost of a regression that the analyzer would have caught is large.
The "pure render" rule is conventional, not enforced. No analyzer catches it; review and snapshot tests are the safety net. The testing page covers the snapshot pattern that makes purity visible.
.WithKey is cheap; reach for it whenever a list reorders. Even
when the analyzer doesn't flag the omission, a missing key is one of
the bugs most likely to ship to a customer (it's invisible at small
list sizes and devastating at scale).
- Hooks — Where rules 1 and 4 are exercised most.
- Components — Render purity in depth.
- Reconciliation — Why keys matter at the reconciler level.
- Theming Tokens — Token catalog for rule 5.
- Accessibility — The A11Y_001..003 rules.
