Reactivity in Bevy: From the Bottom Up (Part 2)

·

6 min read

Context Objects

In the previous post, I briefly mentioned the context objects Cx and Rcx. These objects provide a wealth of methods for doing various reactive stuff, which I'll now go over in more detail.

However, before I get into the nitty-gritty of individual methods, I want to talk about the ways in which context objects are used. The uses of context objects fall roughly into three categories: reading, writing, and setup.

Read contexts allow accessing various reactive entities in an immutable way. For example, in a reactive "if" statement (something we'll see later), the conditional part of the "if" is a read-only reactive expression. Similarly, the closure stored in a derived computation is also read-only: the computation is not expected to mutate anything. These read-only computations are passed an Rcx context object.

Write contexts include reading, but can also modify reactive data sources such as mutables and Bevy resources and components. These kinds of contexts are generally found in event handlers and other callbacks - for example, an on_click handler might want to update the mutable that indicates the widget's is_pressed state.

Setup contexts are ones which allow new reactive entities such as mutables, deriveds, and effects to be created. An example of this is a widget template: when the template runs, it constructs various reactive entities which are then "owned" by the current context.

One difference between the way write contexts and create contexts are used is that generally creation of an object (such as a UI) is only done once during setup, whereas something like an on_click handler may be called many times. While this may seem obvious, it's likely to trip up programmers who have worked with React.js.

Working with React, one develops a set of habits and expectations due to the fact that a React "component" function is called many times. Each time the component updates ("re-renders" in React parlance), the component function is run again, which means that local variables may have different values for each run.

Bevy_reactor, on the other hand, works more like Solid.js, where the setup function is only run a single time, unless the UI is completely destroyed and re-created. Because of this, local variables in the setup function generally do not represent actual data values, but rather signals or getter objects. For example, a button's "pressed" or "disabled" state will be passed around as a Mutable or Signal rather than as a bare boolean. This also implies that the code that reads the signal needs to have a context object - which might not be the same as the context object that created the signal.

Another difference between the way write and create contexts are used is that certain write contexts, such as event handlers, often use direct world access rather than a context object, because a reference to World is easy to get. As mentioned previously, this bypasses the reactive tracking mechanism, but for an event handler that's perfectly fine:

  • For reading, we don't need event handlers to be reactive. The event handler is called each time there's an event; we don't need the event handler to be re-run if a data dependency changed.

  • For writing, changes to mutables, resources, or components will still trigger Bevy's change detection, so other reactions will still happen.

Accessing Resources and Components

In the previous post, we talked about how you can use a context object to create and access Mutable values, as well as deriveds and signals. You can also access regular Bevy resources and components:

let rsx = cx.use_resource::<MyResource>();
let cmp = cx.use_component::<MyComponent>(entity);

These functions will add the requested item to the current tracking scope.

These functions are often used in conjunction with deriveds, for example here's code that creates a derived signal that returns true if a given entity has keyboard focus (as determined by bevy_a11y's Focus resource:

let has_focus = self.create_derived(move |cx| {
    let focus = cx.use_resource::<Focus>();
    focus.0 == Some(target) // `target` is an entity
});

Effects

Another thing you can do is create an effect, which is a function that runs each time it's dependencies have changed:

let counter = cx.create_mutable(0);
cx.create_effect(move |cx| {
    // This will re-run each time counter is updated.
    println!("The count is: {}", counter.get(cx));
});

Internally, this creates an entity with a tracking scope and a Reaction component. This effect lives until its owner is despawned.

Effects also support cleanup handlers, via the cx.on_cleanup() method. This registers a callback which is called just before the next time the effect closure is run; it's also called when the effect is despawned. This provides a way to clean up any additional resources that were created within the effect body. Note that you can call on_cleanup multiple time to register multiple cleanups.

Callbacks

A Callback is a wrapped closure stored in an entity, similar to a Derived, except that it doesn't return a value, and accepts a generic Props argument that allows calling parameters to be stored on the context object. These latter are available via cx.props.

For example, a slider widget might accept an on_change parameter which is called with the updated value of the slider. Because Callback objects are Copy/Clone, they can easily be passed around or used in closures.

let callback = cx.create_callback(|cx: &mut Cx<Props>| {
    println("The new value is: {}", cx.props.value);
});

Misc Context Methods

A few more methods of Cx are worth mentioning here. .create_entity() creates a new, empty Entity:

let id = cx.create_entity();

This is often handy when we need to know the entity id of a widget before we create the widget, so that we can set up reactions using that entity id.

Finally, Cx lets us get access to the world reference stored within it via cx.world() and cx.world_mut().

Context Object Internals

Now that we've covered what context objects can do, let's talk about how they are implemented. A Cx contains a world reference, a set of Props, and a tracking scope:

pub struct Cx<'p, 'w, Props = ()> {
    /// The properties that were passed to the presenter from it's parent.
    pub props: Props,

    /// Bevy World
    world: &'w mut World,

    /// Set of reactive resources referenced by the presenter.
    pub(crate) tracking: RefCell<&'p mut TrackingScope>,
}

The tracking scope is wrapped in a RefCell which allows us to add dependencies to the scope even when Cx is being used in an immutable context. TrackingScope is normally an ECS Component, however in this case we are using a temporary copy which will get consumed and turned into a component once the current execution context is completed.

Speaking of which, what exactly is this "execution context" thing that's been mentioned a few times? In a reactive framework, all "reactions" involve running some code, such as a setup function, view template, or callback. Somewhere within the app there will always be a reference to this code which can be run - and re-run - whenever a dependency changes. The framework ensures that any reactive data sources that were accessed during that run are tracked, because all such accesses go through a Cx context object which holds the tracking scope.

Next Steps

In Part 3, I'll talk about how to use these reactive primitives to construct interactive views.