Skip to content

Effects are commit-phase side effects with dependency tracking. After Microsoft.UI.Reactor (Reactor) finishes reconciling the element tree and patching the WinUI controls for a render, it walks the effect queue for that render and runs every UseEffect body whose dependency array has changed since the previous commit. The body runs after the new UI has been committed — never during render — so an effect is the safe place to start a timer, open a subscription, or kick off a Task.Run that will eventually call a setter to re-render the component. The cleanup function you return is the mirror image: Reactor calls it before re-running the effect with a new dep set, and again when the component unmounts. The dependency array is the contract between you and the scheduler — every value the effect reads from state, props, or context belongs in it. Forget one and the effect captures a stale closure; allocate a new array or lambda each render and the effect re-runs forever because reference identity churns.

Effects and Lifecycle

UseEffect runs side effects — code that reaches outside the component's render function. Timers, data fetching, subscriptions, and DOM manipulation all belong in effects, not in Render().

Reference

Overload When the body runs When cleanup runs
UseEffect(Action body, params object[] deps) After every commit where any entry in deps compares unequal (Array.Empty<object>() → mount only). Never — no cleanup function.
UseEffect(Func<Action> bodyWithCleanup, params object[] deps) Same as above. Before the next body run, and on unmount.

Both overloads accept a params deps argument; pass no values for "run every commit" (rarely correct), Array.Empty<object>() for "run once on mount", or one-or-more reactive values for "re-run when any of these change". Compare with UseResource when the side effect is a cached async read — see Async Resources for the cached-fetch shape, which handles cancellation, retry, and revalidation for you.

Running Once on Mount

Pass an empty dependency array to run an effect once when the component mounts:

class MountEffectExample : Component
{
    public override Element Render()
    {
        var (loadedAt, setLoadedAt) = UseState("");

        UseEffect(() =>
        {
            setLoadedAt(DateTime.Now.ToString("HH:mm:ss"));
        }, Array.Empty<object>());

        return VStack(8,
            TextBlock("Component mounted at:"),
            TextBlock(loadedAt).FontSize(20).Bold()
        ).Padding(24);
    }
}

Mount effect with loaded timestamp

The empty Array.Empty<object>() dependency array tells Reactor this effect has no external dependencies. It runs once after the first render and never again.

Running When Dependencies Change

Pass values in the dependency array to re-run the effect when those values change:

class DependencyEffectExample : Component
{
    public override Element Render()
    {
        var (query, setQuery) = UseState("");
        var (results, setResults) = UseState("Type to search...");

        UseEffect(() =>
        {
            if (string.IsNullOrWhiteSpace(query))
                setResults("Type to search...");
            else
                setResults($"Found 3 results for \"{query}\"");
        }, query);

        return VStack(12,
            TextBox(query, setQuery, placeholderText: "Search...").Width(300),
            TextBlock(results).Foreground(Theme.SecondaryText)
        ).Padding(24);
    }
}

Search with debounced query

Every time query changes, the effect runs again. Reactor compares the current dependencies to the previous ones using structural equality. If nothing changed, the effect is skipped.

Cleanup with Timers

When an effect creates a resource (timer, subscription, event handler), return a cleanup function. Reactor calls it before re-running the effect and when the component unmounts:

class TimerCleanupExample : Component
{
    public override Element Render()
    {
        var (seconds, updateSeconds) = UseReducer(0);
        var (isRunning, setIsRunning) = UseState(false);

        UseEffect(() =>
        {
            if (!isRunning) return () => { };
            var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
            var cts = new CancellationTokenSource();
            _ = Task.Run(async () =>
            {
                while (await timer.WaitForNextTickAsync(cts.Token))
                    updateSeconds(s => s + 1);
            });
            return () => { cts.Cancel(); timer.Dispose(); };
        }, isRunning);

        return VStack(12,
            TextBlock($"Elapsed: {seconds}s").FontSize(24).Bold(),
            HStack(8,
                Button(isRunning ? "Stop" : "Start", () => setIsRunning(!isRunning)),
                Button("Reset", () => updateSeconds(_ => 0))
            )
        ).Padding(24);
    }
}

Timer counting up with start/stop

The Func<Action> overload of UseEffect returns a cleanup function. Here the cleanup disposes the timer, preventing leaks when the component unmounts or when isRunning changes.

The timer body runs on a background thread (it's inside Task.Run) and calls the updateSeconds reducer from there. Once the host is bootstrapped, that works out of the box — every UseState / UseReducer setter automatically marshals onto the captured UI dispatcher when called from a non-UI thread, so timers, PeriodicTimer, network callbacks, and code after await ... ConfigureAwait(false) can all call the returned setter without any extra opt-in. Pass threadSafe: true to the hook only when you need many concurrent setters to apply in-place (locked) instead of being queued one-by-one onto the UI thread. The setter throws InvalidOperationException if it's called cross-thread before any host has bootstrapped (no UI dispatcher captured) or after the dispatcher has begun shutting down — make sure the effect cleanup cancels the background producer so it stops with the component.

Async Data Loading

Use UseEffect with UseState to load data asynchronously:

class AsyncLoadingExample : Component
{
    public override Element Render()
    {
        var (items, setItems) = UseState<string[]?>(null);

        UseEffect(() =>
        {
            _ = Task.Run(async () =>
            {
                await Task.Delay(1500); // simulate network call
                setItems(new[] { "Alice", "Bob", "Charlie" });
            });
        }, Array.Empty<object>());

        if (items is null)
            return TextBlock("Loading...").Padding(24);

        return VStack(8,
            Heading("Loaded Users"),
            VStack(4, items.Select(name => TextBlock(name)).ToArray())
        ).Padding(24);
    }
}

Loading indicator then data

The effect fires on mount, starts an async task, and updates state when it completes. The component re-renders automatically when setItems is called.

Avoiding Infinite Loops

A common mistake is calling a state setter unconditionally inside an effect with that state in its dependency array:

class InfiniteLoopWarning : Component
{
    public override Element Render()
    {
        var (count, setCount) = UseState(0);

        // BAD: this creates an infinite loop!
        // UseEffect(() => { setCount(count + 1); }, count);

        // GOOD: guard with a condition
        UseEffect(() =>
        {
            if (count < 5) setCount(count + 1);
        }, count);

        return TextBlock($"Count stopped at: {count}").Padding(24);
    }
}

This would re-render, re-run the effect, set state again, re-render, and loop forever. Always guard state updates with a condition, or remove the changing value from the dependency array.

Caveat: Cleanup runs before the next effect body, not after. When a dependency changes, the sequence on the next commit is: cleanup of the previous effect instance, then a render with the new dep set, then the new effect body running. So if your effect subscribes to channel A and the dep changes to channel B, you'll see unsubscribe(A) → subscribe(B) in that order — never two simultaneous subscriptions, never a missing unsubscribe between them. The same ordering applies on unmount: cleanup runs once, the effect body never runs again. The concrete failure mode this prevents: a PeriodicTimer that captures cts in the cleanup closure stops correctly when the dep changes, but only if you return () => { cts.Cancel(); timer.Dispose(); } from the body. Forget the cleanup and the old timer keeps firing alongside the new one. The scheduling internals — when each phase runs relative to the reconciler — are documented in effects-scheduling.

Patterns

Data fetching with cancellation

When an effect kicks off an async fetch, the cleanup function must cancel it. Otherwise a stale request can land after the user has navigated away, overwriting state on the next component instance (or worse, throwing on a disposed setter):

UseEffect(() =>
{
    var cts = new CancellationTokenSource();
    _ = Task.Run(async () =>
    {
        try
        {
            var data = await FetchAsync(query, cts.Token);
            setItems(data);
        }
        catch (OperationCanceledException) { /* expected */ }
    });
    return () => cts.Cancel();
}, query);

When query changes, cleanup cancels the in-flight fetch before the new one starts. For the cached / retry / focus-revalidation shape of the same pattern, reach for UseResource instead — it handles all of this plus a per-component cache key for free.

Subscriptions with cleanup

The pattern is the same regardless of what you're subscribing to — INotifyPropertyChanged, an IObservable, a global event bus, a WinUI control's RoutedEventHandler. Subscribe in the body, return an unsubscribe in the cleanup:

UseEffect(() =>
{
    void Handler(object? s, EventArgs e) => setTick(t => t + 1);
    Source.Changed += Handler;
    return () => Source.Changed -= Handler;
}, Source); // re-attach if Source identity changes

If you only need to react to property changes on an INotifyPropertyChanged source, use UseObservable instead — it owns the subscribe/unsubscribe pair so you don't have to.

UseResource for async data

For data that lives outside the component (server fetch, file read, slow computation), reach for UseResource rather than hand-rolling UseEffect + UseState. It returns an AsyncValue<T> with Pending / Value / Error shape, handles cancellation on dep change, retries on await Refresh(), and shares a per-context query cache so two components reading the same key reuse the in-flight task. The pattern to recognize: if the effect body would be "kick off a fetch, write the result to state, handle the loading flag, handle the error flag", you're re-implementing UseResource.

Common Mistakes

Object or array literals in the deps array

// Don't:
var options = new { Url = url, Limit = 10 };
UseEffect(() => FetchAsync(options), options); // new instance every render

options is a fresh anonymous object on every render — its reference compares unequal to the previous one, so the effect re-runs every commit and the fetch fires in a loop. The REACTOR_HOOKS_004 analyzer warns when a hook's deps argument is a freshly-allocated object, array, or lambda. Pass the primitives instead:

UseEffect(() => FetchAsync(url, 10), url);

Or memoize the container with UseMemo so its identity is stable across renders.

Missing cleanup

// Don't:
UseEffect(() =>
{
    var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
    _ = Task.Run(async () =>
    {
        while (await timer.WaitForNextTickAsync())
            setTick(t => t + 1);
    });
}, Array.Empty<object>()); // no cleanup → timer fires forever after unmount

The component unmounts, the timer keeps firing, the setter is called on a dead RenderContext, and the setter throws (or worse, silently leaks the closure tree the timer captured). Always return a cleanup that cancels the producer:

UseEffect(() =>
{
    var cts = new CancellationTokenSource();
    var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
    _ = Task.Run(async () =>
    {
        while (await timer.WaitForNextTickAsync(cts.Token))
            setTick(t => t + 1);
    });
    return () => { cts.Cancel(); timer.Dispose(); };
}, Array.Empty<object>());

Running an effect that should be a memo

// Don't:
var (full, setFull) = UseState("");
UseEffect(() => setFull($"{first} {last}"), first, last);

This pays for two extra renders (initial mount with empty full, then re-render after the effect writes) just to derive a string. The deriving should happen in render, either inline or via UseMemo for an expensive computation:

var full = $"{first} {last}";                  // inline
var stats = UseMemo(() => Compute(input), input); // memoized when expensive

Effects are for side effects — work that reaches outside the component. Pure derivations of existing state belong in render.

Tips

Use empty deps for one-time setup. UseEffect(action, Array.Empty<object>()) is the equivalent of "run on mount." Use it for initial data fetching, event registration, or logging.

Always clean up resources. If your effect creates a timer, subscription, or event handler, return a cleanup function. Leaked resources cause bugs that are hard to trace.

Keep dependency arrays honest. Include every value the effect reads from component state. Omitting a dependency doesn't prevent the read — it prevents the re-run, leading to stale closures.

Don't call setState unconditionally in an effect that depends on that state. This creates an infinite loop. Guard with a condition or restructure the logic.

Prefer UseReducer for complex effect-driven state. When an effect needs to update multiple related values, a reducer keeps the logic in one place and avoids cascading re-renders.

Next Steps

  • Styling and Theming — Previous: apply theme tokens, colors, and dark/light mode
  • Commanding — Next: bundle actions with labels, icons, and accelerators
  • Hooks — Deep dive into UseState, UseReducer, and other hooks used by effects
  • Async Resources — Cached async reads with UseResource and UseMutation
  • Effects Scheduling — When effects run relative to render and commit
  • Advanced Patterns — Combine effects with other hooks for complex scenarios