Organizing your Rodux code
Many Rodux users struggle with organizing their actions, reducers, and selectors. Patterns like putting all of your actions into one big "Actions" folder can start to make navigating your codebase difficult. Some developers have started to adopt the "ducks" or "slice" pattern, which organizes each reducer (and its actions) by which feature they correlate to.
Slices
Slices help you put all of your related Rodux code in the same spot. RoduxUtils
comes with a helper function for quickly generating a slice called
createSlice
. Let's create a slice with it now.
Our slice will contain some data pertaining to the player. We'll track how much money they have, which items they own, and log any transactions made here. For now, we'll only make our reducer handle incrementing the player's money value.
createSlice
uses createReducer
internally, meaning slice reducers are
wrapped in an Immut producer.
return createSlice({
name = "playerData",
initialState = {
money = 0,
items = {},
transactions = {},
},
reducers = {
moneyIncremented = function(state, action)
state.money += action.payload
end,
}
})
Nice! We've set up our first slice. Right now it's pretty simple. Let's take a closer look at it. If we were to print the contents of our slice, it'd look something like this:
{
name = "playerData",
initialState = {
money = 0,
items = {},
transactions = {},
},
reducer = function,
actions = {
moneyIncremented = function,
},
}
It looks like createSlice
is reusing our name
and initialState
inputs,
but it's done something with reducers
. Not only did it create a reducer
function for us, it generated some action creators too! If we plugged this
reducer into our store, we can use it right away.
local store = Rodux.Store.new(playerData.reducer)
-- give the player $100!
store:dispatch(playerData.actions.moneyIncremented(100))
Customizing generated action handlers
So far so good. We've got a working Rodux store and all of the code for it is organized neatly within our slice. Let's add some more functionality. We'll create the handlers for adding and removing items from the player's inventory. Our items are going to be very simple. They'll consist of a numeric ID, a name, and a value (in money).
Since creating an item is a little more complicated than incrementing currency,
we might want to make our action handler a bit easier to use. Even though our
action handlers are automatically generated by createSlice
, we still retain
control over them.
return createSlice({
name = "playerData",
initialState = {
money = 0,
items = {},
transactions = {},
},
reducers = {
moneyIncremented = function(state, action)
state.money += action.payload
end,
itemRemoved = function(state, action)
state.items[action.payload] = nil
end,
itemAdded = {
prepare = function(name, value, id)
return {
payload = {
name = name,
value = value,
id = id,
},
}
end,
reducer = function(state, action)
state.items[action.payload.id] = action.payload
end,
},
},
})
As you can see, our itemAdded
handler has a bit more going on than the
others. That's because we're specifying what our action creator should look
like with the prepare
function. When we want to dispatch itemAdded
, we can
do it like this:
store:dispatch(itemAdded("Name", 100, 1))
Using extraReducers
to add matchers and extra cases
We're almost done. The final piece is logging transactions. We're going to log all of the transactions the player has made. There is a problem, though. Multiple different action types in our game could be considered transactions. Players purchasing items, trading items, and selling items are all transactions that we want to be able to support. For simplicity, we'll make some assumptions about what a "transaction" actually is in our game.
Every transaction can be represented by an action that has a "transactionId" property inside of the "meta" field of an action. With that in mind, we can use a matcher to handle this for us instead of adding a case for every possible transaction. We'll also adjust our existing handlers to support this format.
return createSlice({
name = "playerData",
initialState = {
money = 0,
items = {},
transactions = {},
},
reducers = {
moneyIncremented = function(state, action)
state.money += action.payload
end,
itemRemoved = function(state, action)
state.items[action.payload] = nil
end,
itemAdded = {
prepare = function(name, value, id, guid)
return {
payload = {
name = name,
value = value,
id = id,
},
meta = {
id = guid,
}
}
end,
reducer = function(state, action)
state.items[action.payload.id] = action.payload
end,
},
},
extraReducers = function(builder)
builder
:addMatcher(function(action)
return action.meta.transactionId ~= nil
end, function(state, action)
state.transactions[action.meta.transactionId] = action.payload
end)
end,
})
That was easy! We used extraReducers
to add a matcher to our slice's reducer
with a ReducerBuilder
Now any time an action is
dispatched to our store, regardless of whether or not we've got a case reducer
set up for it.
Combining slice reducers
If you put all of your slices in the same spot, you can easily require them all and combine their reducers before passing them into your store.
local Slices = ReplicatedStorage.Client.Slices
local reducers = {}
for _, module in Slices:GetChildren() do
if module:IsA("ModuleScript") then
local slice = require(module)
reducers[slice.name] = slice.reducer
end
end
local reducer = Rodux.combineReducers(reducers)
local store = Rodux.Store.new(reducer)