Reacting to Component Changes in Bevy with Changed<T> Filters
Use Bevy's Changed<T> and Added<T> query filters to run systems only when components actually mutate, with a counter-based check to verify the filter works.
02 Mar 2026, 14:11 UTC

Your game has a system that recalculates health bars, rebuilds navigation meshes, or syncs a score label — and right now it runs for every entity on every frame, even when nothing moved. Bevy already tracks when components are mutated; the fix is to stop scanning and start filtering with Changed<T> and Added<T> so your system only does work when data actually changed.
This guide walks through wiring up change detection on a concrete example: a system that updates a health-bar UI only when an entity's Health component changes. The examples assume Bevy 0.14; the API moves quickly, so confirm your version in Cargo.toml before copying anything.
What change detection actually tracks
Bevy wraps component and resource access in smart pointers (Mut<T>, ResMut<T>). When you write through one of these via DerefMut, Bevy records a change tick for that component. Query filters then compare those ticks against when your system last ran:
Added<T>matches entities where the component was inserted since the system last ran.Changed<T>matches entities where the component was added or mutably dereferenced since the system last ran. Note thatChangedincludes additions — you rarely needOr<(Added<T>, Changed<T>)>unless you want to treat the two cases differently inside the system.
The important caveat: detection fires on mutable access, not on whether the value is actually different. Calling get_mut() and writing the same value still counts as a change.
Prerequisites
- A Bevy app with
bevy = "0.14"inCargo.toml(checkcargo tree | grep bevyif unsure). - A component defined with
#[derive(Component)]. - A system registered in a schedule such as
Update.
Procedure: filter a system by Changed<Health>
First, the component and a spawner:
use bevy::prelude::*;
#[derive(Component)]
struct Health {
current: f32,
max: f32,
}
fn spawn_player(mut commands: Commands) {
commands.spawn(Health { current: 100.0, max: 100.0 });
}Next, the reactive system. The filter goes in the query type, not the body:
fn update_health_bar(
mut query: Query<&Health, Changed<Health>>,
mut counter: Local<u32>,
) {
for health in &mut query {
*counter += 1;
info!("health bar redraw #{}: {}/{}", *counter, health.current, health.max);
// rebuild the UI here
}
}The Local<u32> counter is a diagnostic: it tells you exactly how many times the redraw logic ran, which you'll use to verify the filter works.
A damage system that mutates health through normal query access:
fn apply_damage(mut query: Query<&mut Health>) {
for mut health in &mut query {
// Writing through `mut health` (DerefMut) marks Health as changed.
health.current -= 10.0;
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, spawn_player)
.add_systems(Update, (apply_damage, update_health_bar).chain())
.run();
}Run with cargo run. You should see redraw logs only on frames where apply_damage wrote to Health — plus once at spawn, because insertion counts as a change for Changed<T>.
Checking that it works
The counter is your verification tool. Two quick checks:
- Idle check: comment out the body of
apply_damageand run. After the initial spawn-triggered redraw, the counter should stop increasing. If it keeps climbing, some other system is mutably accessingHealthevery frame. - Unit test: Bevy apps run headless in tests, so you can assert the counter directly:
#[test]
fn redraw_only_on_change() {
let mut app = App::new();
app.add_systems(Update, update_health_bar);
let entity = app.world_mut().spawn(Health { current: 100.0, max: 100.0 }).id();
app.update(); // first run: insertion counts as a change
app.update(); // second run: nothing mutated
// Inspect via a resource counter in real code; here, assert no panic
// and that a mutation triggers the system again:
app.world_mut().get_mut::<Health>(entity).unwrap().current = 50.0;
app.update(); // should redraw once more
}For a strict assertion, move the counter into a #[derive(Resource)] struct and read it from the world after each app.update().
When it misbehaves
The system never triggers
- You may be replacing the component with
commands.entity(e).insert(Health { ... })— insertion firesAdded(and thereforeChanged), so this should still work, but if you're inserting a different component type than the filter watches, nothing matches. - Check that the mutating system and the reacting system are both actually registered and in a schedule that runs (
Updateruns every frame; a system accidentally placed in a one-shot schedule won't). - Confirm the mutation goes through Bevy's smart pointers. Mutating data behind your own
Arc<Mutex<T>>inside a component changes the value but not the change tick.
The system triggers every frame
Some system is taking &mut Health (or Query<&mut Health> broadly) and dereferencing it unconditionally — even a no-op write like health.current = health.current marks it changed. Temporarily add info! logging to every system that touches the component, or gate writes behind a comparison:
if health.current != new_value {
health.current = new_value; // only marks changed when it really differs
}First-frame surprises
A system's "last run" tick starts at zero, so on its first execution Changed<T> matches everything that exists. If you need to skip that initial pass, add a run condition or a Local<bool> flag.
Limitations
- Change detection is per-component, not per-field. Mutating
health.currentmarks the wholeHealthcomponent changed; you cannot filter on individual fields. - It detects mutable access, not semantic difference — see the no-op write issue above.
- Behavior details (especially
AddedvsChangedsemantics and tick handling) have shifted between Bevy releases. If you're on a version other than 0.14, check the release notes and migration guide for your version before relying on the exact behavior described here.
The practical takeaway: add the filter, add a counter, and watch the counter. If it only moves when your data moves, change detection is doing its job and the per-frame scan is gone.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.