Skip to content

Latest commit

 

History

History
342 lines (277 loc) · 11.9 KB

File metadata and controls

342 lines (277 loc) · 11.9 KB

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:

  1. Hook order is stable across renders.
  2. Render functions are pure.
  3. Lists need stable keys.
  4. Setters returned by hooks are stable; deps must be stable too.
  5. 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.");
    }
}

Trivial app — rules-of-reactor doc-app shell

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.
// 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.

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).

// Don't:
Button("Save", () => { }).Background("#0066CC");   // REACTOR_THEME_001
// Do:
Button("Save", () => { }).Background(Theme.Accent);

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