EventModule - Effortlessly detect table changes!

Introduction:grinning:
Have you ever wanted to detect variable changes inside of tables or mutations in your deep tables as a whole? Then look no further because EventModule is exactly what you need! :star_struck:

Often times I stumble across posts of people asking how they can detect table changes in their scripts similar to how Roblox’s Object Values does it, but this is no simple task. I have seen suggestions about using infinite while loops or setValue functions but all of which just works against you by either making the code less efficient or the development process more tedious. :hot_face:

That’s where EventModule comes in! :partying_face: It mirrors the way Roblox handles it’s Object and makes for an intuitive and familiar workplace for Roblox developers. On top of that, everything is run with metamethods, and events are handled securely so performance hit and memory leaks are kept at a minimum.

There have been similar modules created in the past like @Voidage’s PropertyModule which is a big inspiration for the EventModule. But all of those had some missing pieces with the biggest one being the lack of support for nested and deep tables. This and the other missing pieces is what EventModule is here to solve.

How to use?:thinking:

1. Creating a new EventModule object
local rs = game:GetService("ReplicatedStorage")
local EventModule = require(rs.EventModule ) -- require the module 

-- Creates a new EventModule object with these properties, if no properties are passed the object will be constructed around an empty table.
local Table = EventModule.new({ 
    a = "hello",
    nestedTable = {1,2,3}
})

-- Objects behave just like normal tables (with some exceptions) so it's still manipulated in the same way
print(Table.a) -- > "hello"
Table.b = "Never Gonna Give You Up" 
print(Table.b) -- > "Never Gonna Give You Up"

-- Because Table.nestedTable is passed as a table, it too is converted to an Object
Table.nestedTable  == EventModule.new({1,2,3})

-- Any new nested **tables** added into an object will also be converted to its own object
Table.nestedTable2 = {4,5,6}
Table.nestedTable2 == EventModule.new({4,5,6})

NOTE: The keys: “Parent”, “events”, “properties” and “mutated” are reserved for internal properties and can therefore not be used in the table.

2. The .mutated event

Here comes the juicy stuff. The .mutated event is the EventModule object’s version of the .changed event. It is available in all EventModule objects even in once nested in other objects. It was named .mutation instead of .changed because it is called on tables and not values which makes it more accurate to call it a table mutation and not a value change.

-- Code carries over from last step î

-- Table.mutated returns a "Signal" object which we can connect a function too just like so
Table.mutated:Connect(function(oldTable, newTable) 
    -- oldTable = *raw table* copy of the object before mutation. 
    -- newTable = Table Object after mutation
end)

-- mutation event is fired if the Table has any of its values, created, changed, or deleted
Table.d = "Never gonna let you down" -- > Fires Event
Table.d = "Never gonna run around and desert you" -- > Fires Event
Table.d = "Never gonna run around and desert you" -- > Setting the same value does not Fire the Event
Table.d = nil -- > Fires Event

-- same goes for tables
Table.nestedTable = nil -- > Fires Event
Table.nestedTable = {1,2,3} -- > Fires Event

-- mutation event even fires on any changes within nested tables
Table.nestedTable[4] = 4 -- > Fires Event

In summary, the object.mutation returns a signal that’s fired if the object is mutated in any way, or any of it’s descending deep tables are mutated.

3. GetPropertyChangedSignal()

object:GetPropertyChangedSignal(key) does exactly what it implies, returns a signal that is fired when the property of the corresponding key is created, changed, or deleted.

-- Signal connected to the same way as before
Table:GetPropertyChangedSignal("a"):Connect(function(oldVal, newVal)
   -- oldVal = property's old value
   -- newVal = property's new value
end)

Table.a = nil -- Fires Event
Table.a = "Goodbye" -- FiresEvent
Table.a = "Goodbye" -- Setting the same value does not Fire the Event
Table.b = "skrrrt" -- Table.b is not the property listned for so the event does not fire
4. Parent

Normally a table can only reference nested tables downstream but with the EventModule object you can reference the parent object of any nested table.

local Table = EventModule.new({
    nestedTable = {}
})

nestedTable.Parent == Table

WARNING: This parent structure creates a circular dependency and can cause issues in some cases when using recursion. Use the injected functions and properties documented below to avoid stack overflows.

5. Connection Cleanup

Loose connections and signals no longer in use contribute a lot to lag and memory leaks so EventModule tries to make this cleaning process as easy as possible.

Disconnect:
Disconnects all of the objects events does not alter children’s or parent’s events

self:Disconnect() -- All events previoustly created for self are no longer active

DisconnectDescendants:
Disconnects all events of the object’s Descendants

self:DisconnectDescendants() -- all of self's events are still intact but all objects below self in the table hierarchy have had its events Disconnected.

DisconnectAllParents:
self has all their parents’ Events Disconnected

self:DisconnectAllParents() -- all of self's events are still intact but all objects above self in the table hierarchy have had their events Disconnected.

Destroy:
deletes object and all its events/connections returns: a raw table copy of the object’s properties

    local event = self.mutated:Connect(function)
    local properties = self:Destroy()
    print(event)
     -- > nil
    print(self)
    -- > nil
    print(properties) 
    -- > {a copy of self's properties in raw table form}

Automatically Destroys Objects whos no longer indexed
nested objects who have their index removed gets automatically cleaned up and Destroy()'ed

local Table = EventModule.new({
    nestedTable = {}
})

Table.nestedTable.mutated:Connect(function()
    -- DO SOMETHING WHEN Table.nestedTable  MUTATES
end)

Table.nestedTable[1] = 1 -- > Fires Event
Table.nestedTable = 1 -- > previous nestedTable object can no longer be indexed, the object will therefore be destroyed and cleaned up.

This covers most of the basics of the EventModule. However for more advanced features check out the documentation page below

Documentation :bookmark:
Documentation can also be found commented out at the top of the script.

Summary
--[[

Injected Properties:

    self = EventModule.new(tbl)    
    self.Parent
    self.mutated
    self:GetPropertyChangedSignal(propertyName)
    self:GetProperties()
    self:pairs()
    self:ipairs()
    self:len()
    self:insert(value, pos)
    self:remove(pos)
    self:find(value, init)
    self:Disconnect()
    self:DisconnectDescendants()
    self:DisconnectAllParents()
    self:Destroy()

    Details:
        Parent:

            Table that contains self's parent object

            local tbl = EventModule.new({a = {}})
            a.Parent == tbl

        mutated:

            returns a signal thats fired when the table or any of it's descending nested tables are mutated/changed.

            self.mutated:Connect(function(oldTable, newTable) 
                oldTable = tbl, copy of the table before mutation
                newTable = self Object after change 
            end)

        GetPropertyChangedSignal:

            returns a signal thats fired when a value of that table with the specifiedKey is, 
            created, changed or deleted.

            self:GetPropertyChangedSignal(propertyName):Connect(function(oldVal, newVal)
                oldVal = property's old value
                newVal = property's new value
            end)

        GetProperties:

            Returns: a raw table copy of the object's properties

            local properties = self:GetProperties()
            print(properties) 
            -- > {a copy of self's properties in raw table form}

        pairs:
            
            Using roblox's pairs() function on the object will error so self:pairs() is a replacement for that,
            it returns what you would expect from pairs(self).

            self:pairs() == pairs(self) 

        ipairs:
            
            Using roblox's ipairs() function on the object will error so self:ipairs() is a replacement for that,
            it returns what you would expect from ipairs(self).

            self:ipairs() == ipairs(self) 

        len:

            Using roblox's # length function on the object will error so self:len() is a replacement for that,
            it returns what you would expect from #self.

            self:len() -- > table length

        insert:

               Roblox's table.insert() function uses rawset and therefore does not trigger the mutation and property events,
               self:insert(value, pos) is an identical replacement that adresses this issue.
                    value = value that's inserted
                    pos = optional, number position in table where value will be inserted, defaults to #t+1

               self:insert("LastValue")
               print(self[self:len()]) -- > "LastValue"

        remove:

               Roblox's table.remove() function uses rawset and therefore does not trigger the mutation and property events,
               self:remove(pos) is an identical replacement that adresses this issue.
                    pos = Removes from at position pos, 
                          returning the value of the removed element. 
                          When pos is an integer between 1 and #t, 
                          it shifts down the elements t[pos+1], t[pos+2], …, t[#t] and erases element t[#t]. 
                          The index pos can also be 0 when #t is 0 or #t+1; 
                          in those cases, the function erases the element t[pos].
                
               self = {1,2,3}
               self:remove(2)
               print(self) -- > {1,3}

        find:

               Roblox's table.find() function does not work on self,
               self:find(value, init) is an identical replacement that adresses this issue.
                    value = linear search performed, returns first index that matches "value"
                    init = number pos where linear search starts

               self = {1,2,3,2,1}
               print(self:find(2)) -- > 2
               print(self:find(2, 4)) -- > 4

        Disconnect:

            Disconnects all of the objects events
            does not alter children's or parent's events

            self:Disconnect() -- All events previoustly created for self are no longer active

        DisconnectDescendants:
            
            Disconnects all events of the object's Descendants

            self:DisconnectDescendants() 
            -- all of self's events are still intact but all objects below self in the table hiarchy have had it's events Disconnected.

        DisconnectAllParents

            self has all their parents Events Disconnected

            self:DisconnectAllParents()
            -- all of self's events are still intact but all objects above self in the table hiarchy have had it's events Disconnected.

        Destroy:

            deletes object and all its events/connections
            returns: a raw table copy of the object's properties

            local event = self.mutated:Connect(function)
            local properties = self:Destroy()
            print(event)
            -- > nil
            print(self)
            -- > nil
            print(properties) 
            -- > {a copy of self's properties in raw table form}
            
--]]

Want to contribute? :pray:
EventModule is an open source project so everyone is welcome to contribute. Here is the git-hub repository feel free to submit pull requests and issues :grin:.

Get it here! :small_red_triangle_down:
You can get the Event module either by syncing the git-hub repository directly into your game with rojo or get this assets:

Support me! Follow me here: :grin: :pray:
https://twitter.com/Legenderox

12 Likes

Great! I’ve actually been wanting this for a long time, but I found a way around it. I’ll still get the model, though. Thank you so much! :smile:

2 Likes

Nice, I was thinking about doing something like this. You should also make this compatible with variables.

1 Like

The module is compatible with properties inside of tables or objects but I do not think that is any way currently to detect changes in local variables without running a loop which ruins the whole point of the module. :confused:

Aye. I found a way to get around that:
You make a table with a variable stored in it, and when you change the variable, well, you know. It fires.

1 Like

Due to the Table being circular (Cyclic Table) it will cause an error while being sent over the network.

Can you propose a solution to that?


I would suggest a method for turning EventModule objects back into normal Tables

That method already exists my friend::smiley: If you refer to the Documentation section you will see that the object has a function called :GetProperties(). This returns a raw table of the contents of the object (It also works with nested objects, the function returns the properties of them all). Keep in mind however that this is a copy of the object’s properties, if you want to remove the object you should use :Destroy() instead which actually also returns a raw copy of the contents of the object. Refer to the documentation for more details.