ReactiveUI
Pooling

Component Pooling

When a Repeater is not enough: pools that hand out and take back components, for things that come and go fast or don't fit one list.

For a changing number of things, a Repeater is almost always the better choice, since it builds, reuses, orders and hides the cells from the items you give it. A pool only hands out components and takes them back, so everything else, from where a component goes to what it shows, is left to you. That trade pays off in two cases:

  • Things that come and go many times a second. Any change to a repeater's items walks the whole list and updates every cell's context, even when one item was added. A pool touches only the component you spawn or despawn, and that component shows its data without a context.
  • Components that don't fit one list. A repeater keeps its cells in one container and in list order, while a pooled component can go under any parent, move between parents, be built ahead of time or be shared between screens.

ReactivePool

ReactivePool<T> keeps the components you despawn and hands them out again. Spawn reuses a despawned component when there is one and builds a new one otherwise, and Despawn takes it back. In the demo below, every click on the target shows its points in a popup that floats up and fades within a second, the first case above. Click quickly, and keep an eye on how many popups get built:

partial class Target : ReactiveComponent {
    private ReactivePool<Popup> _pool = null!;
    private State<int> _score = null!;
    private State<int> _built = null!;
    private Background _pad = null!;

    protected override GameObject Construct() {
        _score = Remember(0);
        _built = Remember(0);

        _pool = new ReactivePool<Popup> {
            // Only runs when every popup is in use
            Construct = () => {
                _built.Value++;

                // The same for every use, so it's set once
                return new Popup { OnFinished = Despawn };
            }
        };

        _pad = CreatePad(onHit: Hit);

        return CreateLayout(
            pad: _pad,
            score: _score.Map(x => $"Score: {x}"),
            built: _built.Map(x => $"Popups built: {x}")
        );
    }

    private void Hit(PointerEventsHandler handler) {
        var rect = ((RectTransform)handler.transform).rect;
        var world = handler.EventData!.pointerCurrentRaycast.worldPosition;
        Vector2 local = handler.transform.InverseTransformPoint(world);

        // 100 in the middle, down to 10 in the corners
        var distance = (local - rect.center).magnitude;
        var ratio = distance / (rect.size.magnitude / 2f);
        var points = 100 - Mathf.RoundToInt(ratio * 9f) * 10;

        _score.Value += points;

        // A reused popup still shows its last hit
        var popup = _pool.Spawn();

        popup.Text = $"+{points}";
        // The layout counts from the top left, with y going down
        popup.Point = new Vector2(local.x - rect.xMin, rect.yMax - local.y);
        _pad.Children.Add(popup);
    }

    private void Despawn(Popup popup) {
        _pad.Children.Remove(popup);
        _pool.Despawn(popup);
    }
}

partial class Popup : ReactiveComponent {
    private const float Lifetime = 0.8f;

    public string Text {
        set => _label.Text = value;
    }

    public Vector2 Point {
        set => _point.Value = value;
    }

    public Action<Popup>? OnFinished { get; set; }

    // Spawn enables the popup, so a reused one starts over here
    protected override void OnEnable() {
        _age.Value = 0f;
    }

    protected override void OnUpdate() {
        _age.Value += Time.deltaTime;

        if (_age.Value >= Lifetime) {
            OnFinished?.Invoke(this);
        }
    }
}

However fast you click, the counter stops at the most popups that were on screen at once, because each finished popup goes back to the pool and comes out again for a later hit. A reused popup keeps whatever its last hit left on it, so the code splits its setup by what changes between uses. The finish callback is the same every time, so the pool's Construct sets it once. The text and place differ for every hit and get set after each Spawn, while the popup restarts its own clock in OnEnable, since the pool enables a component when it hands it out.

The pool doesn't know about Children, so you add a component to its parent after Spawn and remove it before Despawn. That's also what lets a component go anywhere, which a repeater can't do. A popup could just as well land in whichever panel matches its score:

var popup = _pool.Spawn();
var panel = points >= 50 ? _goodPanel : _okPanel;

popup.Text = $"+{points}";
panel.Children.Add(popup);

The rest of the pool's members cover setting it up and despawning everything at once:

  • Spawn(enable: true) reuses or builds a component. With false, a reused one stays disabled until you enable it.
  • Despawn(component) takes one back, and throws for a component that didn't come from this pool.
  • DespawnAll() despawns every component in use, and leaves Children to you as well.
  • Preload(count) builds components ahead of time, so the first spawns don't have to.
  • SpawnedComponents lists the components in use.
  • Construct builds a new component, and without it the pool calls new T().
  • DetachOnDespawn decides whether Despawn detaches the component from its parent, and is on by default.

A despawned component moves to the scene root instead of staying under its parent, so it doesn't die with the screen that used it. It waits there disabled until a scene change destroys it.

Keyed pools

When the code that despawns a component only knows what it showed, ReactivePool<TKey, T> saves you a dictionary of your own. It files every spawned component under a key, so SpawnedComponents becomes a dictionary and Despawn can take the key alone. Here's an example where the key is the cell under the pointer, so press an empty cell and drag to paint, or press a painted one and drag to erase:

readonly record struct Cell(int X, int Y);

partial class Pixels : ReactiveComponent {
    private ReactivePool<Cell, Background> _pool = null!;
    private State<int> _painted = null!;
    private State<int> _built = null!;
    private Background _board = null!;
    private bool _erasing;

    protected override GameObject Construct() {
        _painted = Remember(0);
        _built = Remember(0);

        _pool = new ReactivePool<Cell, Background> {
            Construct = () => {
                _built.Value++;
                return CreateTile();
            }
        };

        _board = CreateBoard(
            onDown: x => {
                // A press on a painted cell erases until it's released
                _erasing = _pool.SpawnedComponents.ContainsKey(ToCell(x));
                Apply(ToCell(x));
            },
            onDrag: x => Apply(ToCell(x))
        );

        return CreateLayout(
            board: _board,
            painted: _painted.Map(x => $"Tiles painted: {x}"),
            built: _built.Map(x => $"Tiles built: {x}"),
            onClear: Clear
        );
    }

    private void Apply(Cell cell) {
        if (_erasing) {
            Erase(cell);
        } else {
            Paint(cell);
        }
    }

    private void Paint(Cell cell) {
        // A drag crosses the same cell many times, but it gets one tile
        if (_pool.SpawnedComponents.ContainsKey(cell)) {
            return;
        }

        var tile = _pool.Spawn(cell);

        tile.Color = ColorOf(cell);
        tile.FlexItem.Position = PositionOf(cell);
        _board.Children.Add(tile);

        _painted.Value++;
    }

    private void Erase(Cell cell) {
        // The cell alone is enough to find its tile
        if (!_pool.SpawnedComponents.TryGetValue(cell, out var tile)) {
            return;
        }

        _board.Children.Remove(tile);
        _pool.Despawn(cell);

        _painted.Value--;
    }

    private void Clear() {
        foreach (var tile in _pool.SpawnedComponents.Values) {
            _board.Children.Remove(tile);
        }

        _pool.DespawnAll();
        _painted.Value = 0;
    }
}

A drag crosses the same cell many times, and painting looks the cell up first, so it never gets a second tile. Erasing has nothing but the cell either, and the key is enough to find its tile. A plain pool only lists its components, so it could do neither without a dictionary from cells to tiles that you'd keep in step with it on every change. Erase a few tiles and paint somewhere else, and the built counter stays put, since the new tiles reuse the erased ones. The keyed pool adds three calls to the plain one:

  • Spawn(key) reuses or builds a component for the key, and throws if the key is already in use.
  • Get(key) returns the component under the key, and spawns one when there's none.
  • TryDespawn(key) works like Despawn(key), but does nothing when the key isn't there.

GlobalReactivePool

GlobalReactivePool is one static pool for every component that implements IPoolableComponent. It's meant for a component that's expensive to build and needed by many screens, but rarely by two at once, so one built for one screen serves all the others. Despawned components wait under an inactive object that survives scene changes, which lets the pool outlive every screen that uses it.

A plain pool has one owner, which knows everything it sets on a component and can reset that on its own. Here the next user can be any screen that never saw what the last one set, so the component has to reset itself, and the interface asks for two methods for that:

  • OnSpawn runs every time the pool hands the component out, the first time included, and resets what should start over.
  • OnDespawn runs when the component comes back, and drops everything the last user set, callbacks above all, so the next user gets what looks like a new instance.

Below you can see both in a toast that fades out on its own:

partial class Toasts : ReactiveComponent {
    private const int MaxToasts = 3;

    // Children is a set, so the order the toasts came in is kept here
    private readonly List<Toast> _shown = new();

    private Layout _list = null!;
    private State<int> _built = null!;
    private int _sent;

    protected override GameObject Construct() {
        _list = CreateList();
        _built = Remember(Toast.Built);

        return CreateLayout(
            list: _list,
            status: _built.Map(x => $"Toasts ever built: {x}"),
            onNotify: Notify
        );
    }

    private void Notify() {
        // Makes room by despawning the oldest toast
        if (_shown.Count >= MaxToasts) {
            Despawn(_shown[0]);
        }

        var toast = GlobalReactivePool.Spawn<Toast>();

        toast.Text = $"Notification #{++_sent}";
        toast.OnFinished = Despawn;

        _list.Children.Add(toast);
        _shown.Add(toast);

        _built.Value = Toast.Built;
    }

    private void Despawn(Toast toast) {
        _shown.Remove(toast);
        _list.Children.Remove(toast);
        GlobalReactivePool.Despawn(toast);
    }

    // Toasts still showing would be destroyed along with this screen
    protected override void OnDestroy() {
        foreach (var toast in _shown.ToArray()) {
            Despawn(toast);
        }
    }
}

partial class Toast : ReactiveComponent, IPoolableComponent {
    private const float Lifetime = 2.5f;

    // The global pool builds toasts with new Toast(), so they count
    // themselves
    public static int Built { get; private set; }

    public Action<Toast>? OnFinished { get; set; }

    private State<float> _age = null!;

    protected override void OnUpdate() {
        _age.Value += Time.deltaTime;

        if (_age.Value >= Lifetime) {
            OnFinished?.Invoke(this);
        }
    }

    // Without this, a reused toast would start out expired
    void IPoolableComponent.OnSpawn() {
        _age.Value = 0f;
    }

    // Without this, the next screen's toast would call back into this one
    void IPoolableComponent.OnDespawn() {
        OnFinished = null;
    }
}

Fire a few in a row, and the counter never goes past three, the most toasts the demo shows at once. Each toast counts its age in OnUpdate, so without the reset in OnSpawn a reused one would finish right away, and without clearing the callback in OnDespawn it would call back into whoever used it last.

Spawn<T>(), Despawn(component) and Preload<T>(count) are all the global pool has. A spawned component stays under the pool's inactive object until you add it somewhere, and Despawn throws for one the pool didn't hand out. The SDK's dropdown lists, keyboards and color circles are pooled this way through SharedModal, so a screen with twenty dropdowns builds a single list.

Don't forget that a component still showing dies with its parent like any other, and no pool can reuse it after that. Despawn what's showing in OnDestroy, or the global pool will have to build it again.

On this page