Rules of Reactor¶
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 index¶
| 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.
1. Hook order is stable across renders¶
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.
2. Render functions are pure¶
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.
3. Lists need stable keys¶
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.
4. Setters are stable, and deps must be stable too¶
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.
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.
5. Theme-aware modifiers want a token, not a literal¶
.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).
Full coverage on Theming Tokens.
Reference¶
| 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 |
Tips¶
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).
Next Steps¶
- 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.