Using Selectors
Games often have complex interactions between different parts of the state. Producers let you subscribe to state changes with selectors.
- 🍰 How to write selectors
- 🔐 When to memoize selectors
- 🔥 How to write more powerful selectors
Selecting state
Selectors are functions that take the root state and return a subset of it. They can be as simple as indexing a property, or as complex as filtering and transforming data. We'll go over some examples of selectors, and different ways to use them.
Let's say we had this calendar
slice in our root state, and we wanted to print out all the events in the calendar:
- TypeScript
- Luau
const calendarSlice = createProducer(initialState, {
addEvent: (state, event: CalendarEvent) => ({
...state,
events: [...state.events, event],
}),
});
local calendarSlice = Reflex.createProducer(initialState, {
addEvent = function(state: CalendarState, event: CalendarEvent): CalendarState
local nextState = table.clone(state)
local nextEvents = table.clone(state.events)
table.insert(nextEvents, event)
nextState.events = nextEvents
return nextState
end,
})
With selectors, we can write a function that returns the events from the calendar, and use it to get the events from the root state:
- TypeScript
- Luau
const selectEvents = (state: RootState) => {
return state.calendar.events;
};
for (const event of producer.getState(selectEvents)) {
print(`${event.name} (${event.date})`);
}
local function selectEvents(state: producer.RootState)
return state.calendar.events
end
for _, event in producer:getState(selectEvents) do
print("- " .. event.name .. " (" .. event.date .. ")")
end
# Birthday (2004-12-27)
# Learn Reflex (2023-03-17)
Or, if you want to run code when the selector's value changes, you can subscribe
to it:
- TypeScript
- Luau
producer.subscribe(selectEvents, (events) => {
for (const event of events) {
print(`${event.name} (${event.date})`);
}
});
producer:subscribe(selectEvents, function(events)
for _, event in events do
print("- " .. event.name .. " (" .. event.date .. ")")
end
end)
Pitfall: creating objects in selectors
In the previous examples, the selectEvents
selector is simple and returns the events
property of the calendar slice. But what if you want to get the events sorted by date? Your first thought might be to write a selector like this:
- TypeScript
- Luau
const selectEventsByTime = (state: RootState) => {
// 🔴 This creates a new array every time the selector is called
return table.clone(state.calendar.events).sort((a, b) => {
const timeA = DateTime.fromIsoDate(a.date);
const timeB = DateTime.fromIsoDate(b.date);
return timeA.UnixTimestamp < timeB.UnixTimestamp;
});
};
for (const date of producer.getState(selectEventsByTime)) {
print(`${date.date} - ${date.name}`);
}
local function selectEventsByTime(state: producer.RootState)
-- 🔴 This creates a new array every time the selector is called
local events = table.clone(state.calendar.events)
table.sort(events, function(a, b)
local timeA = DateTime.fromIsoDate(a.date)
local timeB = DateTime.fromIsoDate(b.date)
return timeA.UnixTimestamp < timeB.UnixTimestamp
end)
return events
end
for _, date in producer:getState(selectEventsByTime) do
print(date.date .. " - " .. date.name)
end
# 2004-12-27 - Birthday
# 2023-03-17 - Learn Reflex
This code works, but there's a catch. Every time you call selectEventsByTime
, it will create a new array! And by returning a new value that isn't equal to the last one, you're telling Reflex that the value changed, even if it has the same contents. This can cause problems when you try to subscribe
to the selector:
- TypeScript
- Luau
producer.subscribe(selectEventsByTime, (events) => {
print("events changed!");
});
producer.addTodo("Unrelated todo");
producer:subscribe(selectEventsByTime, function(events)
print("events changed!")
end)
producer.addTodo("Unrelated todo")
# events changed!
Oh no! We added a todo item, which is in an unrelated part of the state, but it still thinks that our sorted events changed! We'll go over how to fix this in the next section.
Transforming state
Right now, our selectEventsByTime
selector creates a new array every time it is called. This is a problem because Reflex runs our selector on every state change, and detects state updates by strict equality (===
). If the selector's new value is different from what it returned last time, it will call the listener.
But if we also can't sort the original array because state is immutable, what can we do to transform state?
Memoization
Memoization is a technique that can be used to cache the result of a function. If the function is called with the same arguments, the cached result can be returned instead of recalculating it.
If we memoize our selectEventsByTime
selector, it will only create a new array when the events
property changes, fixing the problem we had before.
Reflex exports the createSelector
function to create memoized selectors:
- TypeScript
- Luau
import { createSelector } from "@rbxts/reflex";
const selectEvents = (state: RootState) => state.calendar.events;
const selectEventsByTime = createSelector(selectEvents, (events) => {
return [...events].sort((a, b) => {
const timeA = DateTime.fromIsoDate(a.date);
const timeB = DateTime.fromIsoDate(b.date);
return timeA.UnixTimestamp < timeB.UnixTimestamp;
});
});
local Reflex = require(ReplicatedStorage.Packages.Reflex)
local function selectEvents(state: producer.RootState)
return state.calendar.events
end
local selectEventsByTime = Reflex.createSelector(selectEvents, function(events)
local events = table.clone(events)
table.sort(events, function(a, b)
local timeA = DateTime.fromIsoDate(a.date)
local timeB = DateTime.fromIsoDate(b.date)
return timeA.UnixTimestamp < timeB.UnixTimestamp
end)
return events
end)
You'd pass two type of values to createSelector
:
dependencies
, the selectors whose results will be passed to thecombiner
combiner
, a function that takes the results of thedependencies
and returns a new state
The combiner
won't run unless the dependencies and arguments change, so it's safer to do expensive operations and return new objects in it.
With our new memoized selector, the listener will only be called when the events
dependency changes:
- TypeScript
- Luau
producer.subscribe(selectEventsByTime, (events) => {
print("events changed!");
});
// ✅ Will not call the listener
producer.addTodo("Unrelated todo");
task.wait(1);
producer.addEvent({ name: "Learn Reflex", date: "2023-03-17" });
producer:subscribe(selectEventsByTime, function(events)
print("events changed!")
end)
-- ✅ Will not call the listener
producer.addTodo("Unrelated todo")
task.wait(1)
producer.addEvent({ name = "Learn Reflex", date = "2023-03-17" })
# events changed!
Now, we can subscribe to an automatically-sorted list of events efficiently!
Using selectors this way allows you to derive new information from state while keeping your producers and slices simple. This is a common pattern when using Rodux, and it's a good idea to use it in Reflex too.
Memoizing selectors is a good idea, but it's not always necessary. You should prefer to memoize selectors that are expensive or return new objects; indexing a table or returning a primitive value is cheap and doesn't need to be memoized.
Recipes
Passing arguments to selectors
Often, you may want to pass arguments to a selector. For example, you may want to select a specific calendar event by its name. There are two main ways to do this:
- Selector factories that return a selector function for a given set of arguments
- Currying arguments by adding them to the
dependencies
array
There are pros and cons to each approach, but we'll only cover selector factories because they're more ergonomic.
Let's create a selector factory that returns a selector function for a given event name:
- TypeScript
- Luau
import { createSelector } from "@rbxts/reflex";
const selectEvents = (state: RootState) => state.calendar.events;
const selectEventByName = (name: string) => {
return createSelector(selectEvents, (events) => {
return events.find((event) => event.name === name);
});
};
local Reflex = require(ReplicatedStorage.Packages.Reflex)
local function selectEvents(state: producer.RootState)
return state.calendar.events
end
local function selectEventByName(name: string)
return Reflex.createSelector(selectEvents, function(events)
for _, event in events do
if event.name == name then
return event
end
end
end)
end
With our new selector factory, we can create a selector for a specific event:
- TypeScript
- Luau
const selectBirthday = selectEventByName("Birthday");
producer.subscribe(selectBirthday, (event) => {
print(`Birthday is on ${event.date}`);
});
producer.addEvent({ name: "Birthday", date: "2004-12-27" });
local selectBirthday = selectEventByName("Birthday")
producer:subscribe(selectBirthday, function(event)
print("Birthday is on " .. event.date)
end)
producer.addEvent({ name = "Birthday", date = "2004-12-27" })
# Birthday is on 2004-12-27
Selector factories are a nice and simple way to create selectors specialized for a given set of arguments. This pattern can be used to create selectors for a specific user, apply a sort filter, or any other transformation that depends on external arguments.
Custom equality checks
By default, createSelector
uses a strict equality check to compare the results of the dependencies and determine whether to call the combiner
. This is usually fine, but sometimes you may want to use a custom equality check.
Equality of dependencies
createSelector
accepts an optional third argument, options
, which can be used to customize the behavior of the selector. One of the options is equalityCheck
, which can be used to specify a custom equality check.
For example, if you want to only run the combiner
if the dependencies are not shallowly equal, you can use shallowEqual
as the equalityCheck
:
- TypeScript
- Luau
import { createSelector, shallowEqual } from "@rbxts/reflex";
const selectTodos = createSelector(
selectTodoIds,
(ids) => {
for (const _ of $range(0, 10000)) {
// some expensive operation
}
},
{ equalityCheck: shallowEqual },
);
selectTodos(table.clone(state)) === selectTodos(table.clone(state)); // true
local Reflex = require(ReplicatedStorage.Packages.Reflex)
local selectTodos = Reflex.createSelector(selectTodoIds, function(ids)
for i = 1, 10000 do
-- some expensive operation
end
end, {
equalityCheck = Reflex.shallowEqual,
})
selectTodos(table.clone(state)) == selectTodos(table.clone(state)) -- true
Equality of results
createSelector
's options
argument also accepts a resultEqualityCheck
option, which is used to prevent returning a new value unless the result is not "equal" to the previous value. This is useful when the combiner
returns a new object every time it's called, but the result is actually the same.
For example, if you want to only return a new object if the todos
array has changed, you can use shallowEqual
as the resultEqualityCheck
:
- TypeScript
- Luau
import { createSelector, shallowEqual } from "@rbxts/reflex";
const selectTodoIds = createSelector(
selectTodos,
(todos) => {
return todos.map((todo) => todo.id);
},
{ resultEqualityCheck: shallowEqual },
);
selectTodos(table.clone(state)) === selectTodos(table.clone(state)); // true
local Reflex = require(ReplicatedStorage.Packages.Reflex)
local selectTodoIds = Reflex.createSelector(selectTodos, function(todos)
local ids = {}
for _, todo in todos do
table.insert(ids, todo.id)
end
return ids
end, {
resultEqualityCheck = Reflex.shallowEqual,
})
selectTodos(table.clone(state)) == selectTodos(table.clone(state)) -- true
Summary
- You can update the state of a producer by calling actions.
- You can subscribe to a producer to listen for state changes.
- You can create selectors to select a subset of the state and listen for changes.
- Memoizing selectors can improve performance and reduce redundant updates.
- Selector factories can be used to create selectors that depend on external arguments.
- Selector factories can hold their own state.