副作用(Effect)是带依赖追踪的提交期副作用。当 Microsoft.UI.Reactor(以下简称 Reactor)完成元素树的协调、把 WinUI 控件打好补丁之后,它会遍历本次渲染的副作用队列,运行每一个自上次提交以来依赖数组发生了变化 UseEffect 函数体。函数体在新 UI 提交之后运行——绝不会在渲染期间——因此副作用是启动定时器、打开订阅或发起一个最终会调用 setter 来重新渲染组件的 Task.Run 的安全场所。你返回的清理函数则是它的镜像:Reactor 会在带着新依赖集重新运行该副作用之前调用它一次,并在组件卸载时再调用一次。依赖数组是你与调度器之间的契约——副作用从状态、props 或上下文里读取到的每一个值,都应当出现在其中。漏掉一个,副作用就会捕获到过期的闭包;每次渲染都新分配一个数组或 lambda,则副作用会因为引用身份不断变动而永远重跑。
副作用与生命周期¶
UseEffect 用于执行副作用——那些触及组件渲染函数之外的代码。定时器、数据获取、订阅以及控件操作都属于副作用,而不该写在 Render() 里。
速查¶
| 重载 | 函数体何时运行 | 清理何时运行 |
|---|---|---|
UseEffect(Action body, params object[] deps) |
在每一次 deps 中任一项比较不相等的提交之后(Array.Empty<object>() → 仅挂载时)。 |
从不——没有清理函数。 |
UseEffect(Func<Action> bodyWithCleanup, params object[] deps) |
同上。 | 下一次运行函数体之前,以及卸载时。 |
UseEffect<T1>(Action body, T1 d1)(还有 <T1,T2>、<T1,T2,T3> 以及 Func<Action> 版本) |
与 params 重载相同——针对 1–3 个依赖项的按元数定类型的语法糖。 |
同上。 |
两个重载都接受 params 形式的 deps 参数;不传值表示"每次提交都运行"(很少是正确的),传 Array.Empty<object>() 表示"只在挂载时运行一次",传一个或多个响应式值表示"这些值中任一变化时重新运行"。当这个副作用其实是一次带缓存的异步读取时,请对比
UseResource——带缓存的获取形态见异步资源,它会替你处理取消、重试与重新校验。
当你有一个、两个或三个依赖项时,按元数定类型的重载
(UseEffect(body, a, b))可以作为 params 形式的即插即用替代。它们以位置参数而非 params object[] 传递依赖项,因此在依赖项未变的路径上既不分配数组,也绝不会对值类型依赖项(int、
bool、枚举、结构体)装箱。其余行为完全一致——决定函数体是否重跑的是同一套逐元素相等性比较——所以在频繁的渲染路径上请优先使用它们。单个编译期类型为引用类型数组(例如 string[])的依赖项仍然会逐元素比较,与 params 形式一致;而像 int[] 这样的值类型数组则被视为一个按引用比较的依赖项,因此若你想做逐元素比较,就把它的元素作为独立依赖项传入。
只在挂载时运行一次¶
传入一个空依赖数组,让副作用在组件挂载时运行一次:
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);
}
}

空的 Array.Empty<object>() 依赖数组告诉 Reactor:这个副作用没有外部依赖。它会在首次渲染之后运行一次,之后永不运行。
在依赖变化时运行¶
把值放进依赖数组,副作用就会在这些值变化时重新运行:
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...", header: "Search query").Width(300),
TextBlock(results).Foreground(Theme.SecondaryText)
).Padding(24);
}
}

每当 query 变化,副作用就再跑一次。Reactor 会用结构相等性把当前依赖项与上一次的做比较。如果什么都没变,副作用就被跳过。
用清理函数处理定时器¶
当副作用创建了某个资源(定时器、订阅、事件处理器)时,请返回一个清理函数。Reactor 会在重新运行该副作用之前、以及组件卸载时调用它:
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();
var token = cts.Token; // 只捕获一次——循环里不能反复读取 cts.Token
_ = Task.Run(async () =>
{
try
{
while (await timer.WaitForNextTickAsync(token))
updateSeconds(s => s + 1);
}
catch (OperationCanceledException) { /* 清理时的预期行为 */ }
});
return () => { cts.Cancel(); timer.Dispose(); };
}, isRunning);
return VStack(12,
TextBlock($"Elapsed: {seconds}s").FontSize(24).Bold(),
HStack(8,
Button(isRunning ? "Stop" : "Start", () => setIsRunning(!isRunning))
.AutomationName(isRunning ? "Stop timer" : "Start timer"),
Button("Reset", () => updateSeconds(_ => 0))
)
).Padding(24);
}
}

UseEffect 的 Func<Action> 重载返回一个清理函数。这里的清理逻辑会释放定时器,从而在组件卸载或 isRunning 变化时避免泄漏。
定时器循环体运行在后台线程上(它在 Task.Run 内部),并从那里调用 updateSeconds 这个 reducer。宿主启动完成后,这开箱即用——从非 UI 线程调用时,每一个 UseState / UseReducer 的 setter 都会自动封送到已捕获的 UI 调度器上,因此定时器、PeriodicTimer、网络回调,以及 await ... ConfigureAwait(false) 之后的代码,都可以直接调用返回的 setter,无需任何额外启用。只有当你需要大量并发 setter 就地(加锁)生效、而不是一个个排队到 UI 线程上时,才给 Hook 传 threadSafe: true。若 setter 在跨线程调用时、尚无任何宿主启动完成(未捕获到 UI 调度器),或调度器已开始关闭,它会抛出
InvalidOperationException——请确保副作用的清理逻辑取消了后台生产者,让它随组件一起停下。
异步数据加载¶
把 UseEffect 与 UseState 配合使用来异步加载数据:
class AsyncLoadingExample : Component
{
public override Element Render()
{
var (items, setItems) = UseState<string[]?>(null);
UseEffect(() =>
{
_ = Task.Run(async () =>
{
await Task.Delay(1500); // 模拟网络调用
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).WithKey(name)).ToArray())
).Padding(24);
}
}

副作用在挂载时触发,启动一个异步任务,并在任务完成时更新状态。调用 setItems 时组件会自动重新渲染。
避免无限循环¶
一个常见的错误是:在一个副作用里无条件地调用某个状态 setter,而该状态又出现在它的依赖数组中:
class InfiniteLoopWarning : Component
{
public override Element Render()
{
var (count, setCount) = UseState(0);
// 不好:这会造成无限循环!
// UseEffect(() => { setCount(count + 1); }, count);
// 好:用条件加以保护
UseEffect(() =>
{
if (count < 5) setCount(count + 1);
}, count);
return TextBlock($"Count stopped at: {count}").Padding(24);
}
}
那样做会重新渲染、重跑副作用、再次设置状态、再重新渲染,然后永远循环下去。请始终用条件保护状态更新,或把那个不断变化的值从依赖数组中移除。
注意: 清理运行在下一个副作用函数体之前,而不是之后。当某个依赖变化时,下一次提交中的顺序是:先清理上一个副作用实例,然后带着新依赖集渲染,再运行新的副作用函数体。因此,如果你的副作用订阅了频道 A,而依赖项变成了频道 B,你看到的顺序会是
unsubscribe(A) → subscribe(B)——绝不会出现两个同时存在的订阅,中间也绝不会漏掉一次退订。卸载时是同样的顺序:清理运行一次,副作用函数体永不再运行。这个顺序所防止的具体故障形态是:一个在清理闭包中捕获了cts的PeriodicTimer,只有在你从函数体返回() => { cts.Cancel(); timer.Dispose(); }时,才能在依赖变化后正确停止。 忘了清理,旧的定时器就会与新的那个一起继续触发。至于各个阶段相对于协调器何时运行的调度内部机制,记录在 effects-scheduling 中。
模式¶
带取消的数据获取¶
当副作用发起一次异步获取时,清理函数必须取消它。否则一个过期的请求可能在用户已经离开之后才落地,覆盖掉下一个组件实例上的状态(更糟的情况下,还会因为 setter 已释放而抛异常):
class FetchCancellationExample : Component
{
static Task<string[]> FetchAsync(string q, CancellationToken ct) =>
Task.FromResult(new[] { $"{q} result" });
public override Element Render()
{
var (query, setQuery) = UseState("reactor");
var (items, setItems) = UseState(Array.Empty<string>());
UseEffect(() =>
{
var cts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
try
{
var data = await FetchAsync(query, cts.Token);
setItems(data);
}
catch (OperationCanceledException) { /* 预期行为 */ }
});
return () => { cts.Cancel(); };
}, query);
return VStack(8,
TextBox(query, setQuery, header: "Search query").Width(250),
ForEach(items, item => TextBlock(item).WithKey(item))
).Padding(24);
}
}
当 query 变化时,清理会在新的请求开始之前取消那次在途的获取。同一模式的"带缓存 / 可重试 / 聚焦时重新校验"形态,请改用
UseResource——它包办了这一切,还免费提供一个按组件划分的查询缓存键。
带清理的订阅¶
无论你订阅的是什么——INotifyPropertyChanged、一个 IObservable、一个全局事件总线、还是某个 WinUI 控件的 RoutedEventHandler——模式都一样:在函数体中订阅,在清理中退订:
class EventSource
{
public event EventHandler? Changed;
public void Fire() => Changed?.Invoke(this, EventArgs.Empty);
}
class SubscriptionCleanupExample : Component
{
static readonly EventSource Source = new();
public override Element Render()
{
var (tick, updateTick) = UseReducer(0);
UseEffect(() =>
{
void Handler(object? s, EventArgs e) => updateTick(t => t + 1);
Source.Changed += Handler;
return () => Source.Changed -= Handler;
}, Source); // 若 Source 的身份变化则重新挂载
return VStack(8,
TextBlock($"Ticks: {tick}"),
Button("Fire", Source.Fire)
).Padding(24);
}
}
如果你只需要响应某个 INotifyPropertyChanged 数据源上的属性变化,请改用 UseObservable——它自己拥有订阅/退订这一对操作,你就不用操心了。
对异步数据使用 UseResource¶
对于存在于组件之外的数据(服务端获取、文件读取、耗时计算),请使用 UseResource,而不是手工拼一个 UseEffect + UseState。它返回一个带 Pending / Value / Error 形态的 AsyncValue<T>,在依赖变化时处理取消、在 await Refresh() 时重试,并共享一个按上下文划分的查询缓存,让读取同一个键的两个组件复用同一个在途任务。要认出这个模式:如果你的副作用函数体会写成"发起一次获取、把结果写进状态、处理加载标志、处理错误标志",那你就正在重新实现 UseResource。
常见错误¶
依赖数组里放对象或数组字面量¶
class DepsLiteralDontExample : Component
{
static Task FetchAsync(object options) => Task.CompletedTask;
public override Element Render()
{
var (url, setUrl) = UseState("https://example.test");
// 不要这样——`options` 每次渲染都是一个全新的匿名对象,
// 于是副作用每次提交都会重跑,请求陷入循环。
var options = new { Url = url, Limit = 10 };
UseEffect(() => FetchAsync(options), options);
return TextBox(url, setUrl, header: "Request URL").Width(250).Padding(24);
}
}
options 每次渲染都是一个全新的匿名对象——它的引用与上一个比较不相等,于是副作用每次提交都重跑,请求陷入循环。当某个 Hook 的 deps 参数是一个刚分配出来的对象、数组或 lambda 时,REACTOR_HOOKS_004 分析器会发出警告。请改为传递基元值:
class DepsLiteralDoExample : Component
{
static Task FetchAsync(string url, int limit) => Task.CompletedTask;
public override Element Render()
{
var (url, setUrl) = UseState("https://example.test");
// 应当这样——传基元值,它们按值比较。
UseEffect(() => FetchAsync(url, 10), url);
return TextBox(url, setUrl, header: "Request URL").Width(250).Padding(24);
}
}
或者用 UseMemo 把那个容器记忆化,让它的身份在渲染之间保持稳定。
缺少清理¶
class MissingCleanupDontExample : Component
{
public override Element Render()
{
var (tick, updateTick) = UseReducer(0);
// 不要这样——没有清理,定时器会在卸载之后永远触发。
// UseEffect(() =>
// {
// var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
// _ = Task.Run(async () =>
// {
// while (await timer.WaitForNextTickAsync())
// updateTick(t => t + 1);
// });
// }, Array.Empty<object>());
return TextBlock($"Ticks: {tick}").Padding(24);
}
}
如果你把那段被注释的副作用启用起来,组件卸载之后定时器仍会继续触发,setter 会被调用在一个已失效的 RenderContext 上,于是 setter 抛异常(更糟的情况下,定时器所捕获的那棵闭包树被静默泄漏)。请始终返回一个取消该生产者的清理函数:
class MissingCleanupDoExample : Component
{
public override Element Render()
{
var (tick, updateTick) = UseReducer(0);
UseEffect(() =>
{
var cts = new CancellationTokenSource();
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
var token = cts.Token; // 只捕获一次——循环里不能反复读取 cts.Token
_ = Task.Run(async () =>
{
try
{
while (await timer.WaitForNextTickAsync(token))
updateTick(t => t + 1);
}
catch (OperationCanceledException) { /* 卸载时的预期行为 */ }
});
// 取消令牌源并释放定时器;不要释放令牌源本身。
// 那个即发即忘的工作线程与它共享所有权,而
// CancellationTokenSource.Dispose 在并发成员访问下并不安全——
// 一个仍在向该令牌注册的调用会看到 ObjectDisposedException。
// 不带定时器的 CTS 不持有非托管资源,因此丢弃引用就够了。
return () => { cts.Cancel(); timer.Dispose(); };
}, Array.Empty<object>());
return TextBlock($"Ticks: {tick}").Padding(24);
}
}
本该是记忆化的副作用¶
class EffectVsMemoDontExample : Component
{
public override Element Render()
{
var (first, setFirst) = UseState("Ada");
var (last, setLast) = UseState("Lovelace");
// 不要这样——为了推导出一个字符串,多付了两次渲染的代价。
var (full, setFull) = UseState("");
UseEffect(() => setFull($"{first} {last}"), first, last);
return VStack(8,
TextBox(first, setFirst, header: "First name").Width(150),
TextBox(last, setLast, header: "Last name").Width(150),
TextBlock(full)
).Padding(24);
}
}
这为了推导出一个字符串,多付了两次渲染的代价(先以空的 full 挂载,再在副作用写入后重新渲染)。推导应当发生在渲染期间——要么内联,要么在计算昂贵时用
UseMemo:
class EffectVsMemoDoExample : Component
{
static string Compute(string input) => input.ToUpperInvariant();
public override Element Render()
{
var (first, setFirst) = UseState("Ada");
var (last, setLast) = UseState("Lovelace");
var full = $"{first} {last}"; // 内联
var stats = UseMemo(() => Compute(full), full); // 昂贵时做记忆化
return VStack(8,
TextBox(first, setFirst, header: "First name").Width(150),
TextBox(last, setLast, header: "Last name").Width(150),
TextBlock(full),
TextBlock(stats)
).Padding(24);
}
}
副作用适用于副作用——那些触及组件之外的工作。对既有状态的纯粹推导属于渲染。
小贴士¶
一次性初始化请用空依赖。 UseEffect(action, Array.Empty<object>())
等价于"在挂载时运行"。把它用于初始数据获取、事件注册或日志记录。
总是清理资源。 如果你的副作用创建了定时器、订阅或事件处理器,就返回一个清理函数。泄漏的资源会造成难以追踪的 bug。
让依赖数组保持诚实。 把副作用从组件状态中读取的每一个值都放进去。省略某个依赖项并不能阻止读取——它只是阻止了重跑,从而导致过期闭包。
不要在依赖该状态的副作用里无条件地调用 setState。 这会造成无限循环。请用条件加以保护,或重构这段逻辑。
复杂且由副作用驱动的状态,优先用 UseReducer。 当一个副作用需要更新多个相互关联的值时,reducer 能把逻辑集中在一处,并避免级联的重新渲染。