Skip to main content

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.

tip

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)