Deriving data from State
Ideally, your Rodux store should contain the least amount of data it possibly can while still being useful as the single source of truth. Any additional data can be derived from that state when you need it. Rodux stores contain global state. They shouldn't be concerned with filtering or sorting data for your UI. Your UI should be doing that!
Selectors
Selectors are functions that encapsulate the logic for deriving data from state. They allow you to keep your state minimal by shifting the burden of calculating more complicated data to the consumer of the state.
We'll use this pattern in the following example to filter some data. Our state contains a list of all of the players ingame right now. We're going to create a filter that only returns players who are on the blue team.
Here's what our state looks like:
local state = {
players = {
{ username = "Alex", team = "green" },
{ username = "Ben", team = "red" },
{ username = "Matt", team = "blue" },
}
}
And here's how we'll filter it with a selector:
local function selectBlueTeam(state)
local blueTeam = {}
for _, player in state.players do
if player.team == "blue" then
table.insert(blueTeam, player)
end
end
return blueTeam
end
Now we can get all of the Blue team's players from our state when it changes.
-- A Roact component somewhere...
local function blueTeamList(props)
local blueTeam = useSelector(selectBlueTeam)
...
end
As you can see, this made it pretty easy to get exactly what we were looking for without having to split our state up to accomodate the team feature of our game. We don't need a reducer for teams, we can just derive team data from our existing players reducer.
There is a problem with this implementation, though. Every time the store
updates, we're going to call that selectBlueTeam
selector. It might not be
that bad with only four players, but as the size of the server expands, that
filter is going to become slower. It would be nice if we could only run the
selector when we needed to.
Memoization & createSelector
The basics
Memoization is a technique that prevents calling a pure function when the
result would be the same as the last time it was called. It does this by
caching the result of the function and the arguments passed to it. Since a
selector is a pure function, we can memoize it! RoduxUtils includes a helper
for creating memoized selectors named createSelector
.
Let's rewrite our selectBlueTeam
selector using createSelector
.
local function selectPlayers(state)
return state.players
end
local selectBlueTeam = createSelector({ selectPlayers }, function(players)
local blueTeam = {}
for _, player in players do
if player.team == "blue" then
table.insert(blueTeam, player)
end
end
return blueTeam
end)
There's a lot to unpack here! Let's start with the selectPlayers
function.
It's another selector, but it's very simple. Even simpler than our original
selectBlueTeam
selector.
So, what's its purpose here? selectPlayers
is an input selector. Under the
hood, createSelector
is passing the result of selectPlayers
to our result
function, which, in this case, is the function responsible for actually
filtering the players. Since our state is immutable, that means if nothing in
state.players
has changed, it's going to be the same value that was passed to
our result function before.
That's where memoization kicks in! Since createSelector
memoizes the result
function for us, it won't run it again unless state.players
has changed.
Passing arguments
The usefulness of createSelector
doesn't end there, though. You can also use
it to create selectors that are capable of taking more arguments than just
state
. Our selectBlueTeam
selector would be a lot more useful if it could
select any team we wanted, wouldn't it? Let's rewrite it again with that in
mind.
local function selectPlayers(state)
return state.players
end
local function selectTeamName(state, teamName)
return teamName
end
local selectTeam = createSelector({
selectPlayers,
selectTeamName,
}, function(players, teamName)
local team = {}
for _, player in players do
if player.team == teamName then
table.insert(team, player)
end
end
return team
end)
That'll work, but how do we use it? Where is the teamName
argument coming
from? When you call a selector created with createSelector
, it can take as
many arguments as you'd like. The first argument should always be your state.
To use this selector, we'd call it like so:
selectTeam(state, "red")
Custom memoization settings
Increasing the cache size
By default, createSelector
will only cache the last result. You can tweak this
to your liking by passing in an additional configuration argument.
local selectTeam = createSelector({ selectPlayers }, function(players)
...
end, {
-- set the cache size to 3
maxSize = 3,
})
Now we'll cache the last 3 results of our result function. The included memoization function uses an LRU cache when the size is greater than 1.
Reducing recomputations with custom equality checks
You can change the function used to check for equality between old and new arguments that are passed to your selector. This might be useful when the result of one of your input selectors has some nested values that you want to pay extra attention to.
For our example, let's take our selectors for the red and blue team and use them as our inputs. With them, we'll create a new purple team. Since our red and blue team selectors return a new table every time we run them, we will have to check the contents of each table to be sure that they're actually different. Otherwise, our result function will run every time the players in our state change, even if they aren't relevant to the purple team!
local inputSelectors = { selectRedTeam, selectBlueTeam }
local selectPurpleTeam = createSelector(inputSelectors, function(red, blue)
local purpleTeam = {}
for _, player in red do
table.insert(purpleTeam, player)
end
for _, player in blue do
table.insert(purpleTeam, player)
end
return purpleTeam
end, {
-- returns true if red & blue team's players are the same as they were
-- the last time the function was called
equalityCheck = shallowEquals,
})
Nice! We've created a new team just by deriving data from what's available in our state.
It doesn't end there! There's actually another way to solve this problem. We
can use resultEqualityCheck
to check the equality of a result. Remember how we
had to use a custom equalityCheck
because our selectors were returning a new
table every time? We can avoid that problem entirely this way.
We'll only refactor selectBlueTeam
for now.
local function selectPlayers(state)
return state.players
end
local selectBlueTeam = createSelector({ selectPlayers }, function(players)
local blueTeam = {}
for _, player in players do
if player.team == "blue" then
table.insert(blueTeam, player)
end
end
return blueTeam
end, {
resultEqualityCheck = shallowEquals,
})
That's all it needed! Now, when all of the players on the blue team are the
same as they were before, the selector will return the old table instead of a
new one. This can help with avoiding unnecessary reconciliation in a Roact
component, or prevent another selector that uses this one as an input from
running again. Like our selectPurpleTeam
selector!
If you made this change to the selectRedTeam
selector as well, you'll have
solved the problem that required us to use a shallow comparison for the
arguments passed to the selectPurpleTeam
selector. Neat!