Skip to main content

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!