Selectors
Imagine a list of 500 rows where exactly one is highlighted. When the player clicks a different row, the highlight must move. The naive approach is for every row to ask "am I the selected one?", but that means all 500 rows re-evaluate on every selection change, even though only two of them actually flipped state.
Flux.selector solves this. It is the Flux equivalent of SolidJS's createSelector: an O(1) keyed selection primitive that, when the selection moves from one key to another, re-evaluates only the two affected rows, not the whole list.
Creating a Selector
Create one with Flux.selector; created inside a Scope, it is torn down with that scope. It takes a source (the currently selected key) and returns a callable object.
The source can be any of three things:
- A reactive node (a
FluxSignal, Computed, etc.). - A function that reads reactive nodes, like a Computed body.
- A plain value (rarely useful, since it never changes).
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Flux = require(ReplicatedStorage.Flux)
-- The currently selected row id (a reactive Signal)
local selectedId = Flux(1)
-- A selector keyed by row id
local isSelected = Flux.selector(selectedId)Reading a Selection
The returned object is callable. Call it with a key to reactively read whether that key is the currently selected one:
isSelected(42) -- true if selectedId() == 42, false otherwiseisSelected(key) is exactly equivalent to isSelected:get(key); the call form is just sugar. Either way, the read is reactive: it makes the calling computation or binding depend on just that one key. A row binding on isSelected(row.id) only re-runs when row.id's selected state actually changes, not when some other row's does.
NOTE
A per-key node is created lazily on a key's first read from inside a reactive computation or binding. A key never read that way costs nothing, and an imperative isSelected(key) check in plain code (e.g. an event handler) returns the current answer without allocating a node at all. So the selector scales with the number of rendered rows, not the size of your underlying data.
Why It's Fast
This is the point of a selector, so here is the contrast explicitly.
Consider a binding that reads the selection directly:
-- Naive: re-runs for EVERY row on EVERY selection change
Highlighted = function()
return row.id == selectedId()
endEvery row's binding depends on selectedId, so changing it dirties all 500 bindings, and the flush re-evaluates all 500, even though 498 of them return the same value they did before.
A selector inverts this. The selector keeps one internal watcher that is the only thing tracking the source. Each row instead depends on its own per-key node. When the selection moves from key A to key B, the watcher flips exactly two nodes (A to false and B to true) and only the two rows depending on those keys re-evaluate:
-- O(1): only the two affected rows re-run
Highlighted = function()
return isSelected(row.id)
endIn a 500-row list, moving the highlight touches 2 rows, not 500.
A Worked Example
Here is a selectable list built with a mapped list. Each row binds its highlight to isSelected(row.id), and clicking a row sets the selected id. Only the previously-selected row and the newly-selected row ever re-render their color.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Flux = require(ReplicatedStorage.Flux)
local new = Flux.new
local rows = Flux({
{ id = 1, label = "Sword" },
{ id = 2, label = "Shield" },
{ id = 3, label = "Potion" },
})
-- Currently selected row id
local selectedId = Flux(1)
-- O(1) selector keyed by row id
local isSelected = Flux.selector(selectedId)
local list = new "Frame" {
Name = "Inventory",
Flux.forValue(rows, function(_, row)
return new "TextButton" {
Name = row.label,
Text = row.label,
-- Reactive, but only flips when THIS row's selected state changes
BackgroundColor3 = function()
return if isSelected(row.id)
then Color3.fromRGB(80, 140, 255)
else Color3.fromRGB(40, 40, 40)
end,
-- Clicking sets the selection; the selector flips just two nodes
Activated = function()
selectedId(row.id)
end,
}
end),
}When the player clicks "Shield" while "Sword" is selected, only the "Sword" and "Shield" buttons recompute their BackgroundColor3. "Potion" (and every other row in a larger list) is untouched.
Custom Matchers
By default, a selector matches with ==: a key is selected when it is equal to the source value. You can pass a custom matcher as the second argument when "selected" means something more involved than identity:
-- Select every row whose id falls inside a chosen range
local range = Flux({ min = 2, max = 5 })
local inRange = Flux.selector(range, function(value, key)
return key >= value.min and key <= value.max
end)The matcher receives (sourceValue, key) and returns a boolean.
CAUTION
The custom-matcher path is not O(1). The default == path can flip exactly the two affected nodes because it knows precisely which key the old and new source values correspond to. A custom comparator's match is opaque (Flux cannot know which keys it newly includes or excludes), so on every source change it must re-test every per-key node it has created so far (one per key ever read): O(live keys). Use a custom matcher when you need range or predicate selection, but prefer the default for plain single-selection.
Cleaning Up
A selector holds an internal watcher and one node per key it has seen. When you no longer need it, destroy it to tear all of that down:
isSelected:destroy()Created inside a Flux.scope, the selector is destroyed automatically when that scope is destroyed, so you never have to track it by hand.