Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Library: AceState! A state lifecycle management library implementing the action/reducer pattern to update immutable state. Works well in tandem with AceDB. #22

Closed
wants to merge 1 commit into from

Conversation

chevcast
Copy link

@chevcast chevcast commented Sep 9, 2024

As a web developer I've gotten used to so many quality of life tools and libraries. When I invariably come back to dabble in WoW addons with Lua I quickly remember how much low level work will be involved once an addon starts to become reasonably complex. One of these annoyances is keeping state manageable. Most recently I had to synchronize data between multiple clients to keep quest data updated amongst all players in the same party for my QuestTogether addon. This took an already working addon and immediately ballooned the complexity by a couple orders of magnitude.

I now had many functions trying to modify the same state. The addon has to work while solo too so I can't rely on party comm messages for local operations. For example, I'd have to update a giant questTracker object with the player's current quests and objectives, but now that I want each client to keep track of each player's quests in a party I had to expand the object to contain multiple quest trackers by character name and I had to create broadcasts that would send out quest updates from each client to all other clients so they too could update their copy of each player's quests.

Basically I had to run similar logic in comm received functions and various WoW event handler functions. It was becoming quite unweildy to keep track of it all. I needed a proper state machine. That's when it hit me that it's time for me to build a library and implement the action/reducer pattern. But then I quickly realized that this functionality would fit perfectly into the Ace3 suite! So rather than publish my own standalone addon I figured I'd take a stab at adding it here. If for some reason this is not desired then I'll go ahead and publish a standalone library, but hopefully you feel similarly that this is a elegant and lightweight solution that adds a cool way to reduce addon state management complexity without adding unnecessary bloat to Ace3 itself

Without further ado, here is a basic usage example showing how one would use AceState:

StepCounterAddon = LibStub("AceAddon-3.0"):NewAddon(
    "QuestTogether",
    "AceConsole-3.0",
    "AceEvent-3.0",
    "AceState-3.0",
    "AceTimer-3.0"
)

StepCounterAddon.defaultOptions = {
    char = {
        steps = 0,
    },
}

function StepCounterAddon:OnInitialize()
    self.db = LibStub("AceDB-3.0"):New("StepCounterDB", self.defaultOptions, true)

    -- Here we register our reducers with AceState.
    -- Once registered we can dispatch actions to them via self:Dispatch.
    self:RegisterReducer("addon", self.db.global, "AddonReducer")
    -- We can also get reference to a reducer-specific dispatch function
    -- by storing the function returned from RegisterReducer.
    local stepsDispatch = self:RegisterReducer("steps", self.db.char, "StepsReducer")

    -- Example action dispatching.
    -- stepsDispatch("SET_STEPS", { steps = 5 })

    -- Typically it is simplest to just use the global dispatch method.
    -- self:Dispatch("SET_STEPS", { steps = 5 })
    -- self:Dispatch can dispatch any action from any registerd reducers.
    -- If two reducers define the same action then both actions would be
    -- dispatched when using self:Dispatch.

    self:Print("Step Counter Addon initialized.")
end

function StepCounterAddon:OnEnable()
    self:RegisterEvent("PLAYER_STARTED_MOVING")
    self:RegisterEvent("PLAYER_STOPPED_MOVING")

    self:Print("Step Counter Addon enabled.")
end

function StepCounterAddon:OnDisable()
    self:Print("Step Counter Addon disabled.")
end

function StepCounterAddon:AddonReducer(state, action, payload)
    if action == "ENABLE_ADDON" then
        self:Enable()
    elseif action == "DISABLE_ADDON" then
        self:Disable()
    else
        error("Unknown action type: " .. action)
    end
    -- This reducer didn't actually modify state, so we just return the original state.
    return state
end

function StepCounterAddon:StepsReducer(state, action, payload)
    -- state is a deep copy of self.db.char and can be modified directly
    -- ensuring original state is immutable (not modified)
    if action == "INCREMENT_STEPS" then
        if not payload.increment then
            error("INCREMENT_STEPS Error: No increment value provided.")
        end
        -- Only by returning a new state object will AceState update the
        -- original state object at self.db.char
        return {
            steps = state.steps + payload.increment,
        }
    elseif action == "RESET_STEPS" then
        return {
            steps = 0,
        }
        elseif action == "SET_STEPS" then
        if not payload.steps then
            error("SET_STEPS Error: No steps value provided.")
        end
        return {
            steps = payload.steps,
        }
    else -- unknown action
        error("Unknown action type: " .. action)
    end
    -- NOTE: Whatever is returned from the reducer replaces state entirely.
    -- In this case, self.db.char is the original state object reference
    -- so it will be replaced with the new state object.
    -- This is done by transplanting the new state object's key-value pairs
    -- onto the original state object. This ensures that any references
    -- to the original state object remain valid.
end

function StepCounterAddon:PLAYER_STARTED_MOVING()
    self.timerId = self:ScheduleRepeatingTimer(function()
        -- We can trigger any action in any reducer by using the embedded Dispatch method.
        self:Dispatch("INCREMENT_STEPS", { increment = 1 })
    end, 1)
end

function StepCounterAddon:PLAYER_STOPPED_MOVING()
    self:CancelTimer(self.timerId)
end

@chevcast
Copy link
Author

chevcast commented Sep 9, 2024

I have not tested this thoroughly yet so I have left this PR marked as a draft. I'll be implementing this in my actual addon shortly and testing it over the next few days. When I'm satisfied it's working as intended and stable then I will turn this into a non-draft PR.

end

-- Return a dispatch function for this reducer
local dispatch = function(actionType, payload)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create the actual dispatch function that will invoke this reducer when called. We return this dispatch function directly from RegisterReducer but we also store it on self.dispatchers so our self:Dispatch method can iterate over each reducer's dispatch function and invoke it.

if newState[k] == nil then
self.state[name][k] = nil
end
end
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit here is logic that transplants the updated state properties onto the original state object. By transplanting properties onto the original object we ensure that any references to that original object remain intact. If we simply overwrote the original object with the new state then any existing references to the original object would be invalid.

for _, dispatch in pairs(self.dispatchers) do
dispatch(actionType, payload)
end
end
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main "meta" dispatch function, invokes all other stored dispatch functions.

@Nevcairiel
Copy link
Contributor

In general, I think you would be better served with a standalone library. There is really no benefit of including it, considering you can literally just take your file and publish it as-is separately, perhaps with the library renamed.

The all in one package design is from a long time ago, and today I would probably not do it like this anymore, especially when a library is entirely independent and does not depend on any of the others (which we designed most to be, but not all could do that)

As for some general feedback, on a first glance I think "Dispatch" name for an embed function is a bit too generic, maybe DispatchState would be better. Also thinking about the dispatching and how your reducer function still has to carry a lot of it, it feels like a simple CBH dispatcher would cover about 90% of this already.

res[self.DeepCopy(k, s)] = self.DeepCopy(v, s)
end
return res
end
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deep copy logic ensures that reducers only get a cloned state object, preventing them from modifying the original object by reference. Keeping it immutable within the state machine.

@chevcast
Copy link
Author

chevcast commented Sep 9, 2024

Got it. I'll take my code elsewhere. Thanks for taking a look.

@chevcast chevcast closed this Sep 9, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants