[Resource] Simple StateTracker | Reactive Client/Server Synchronization

(has bugs, use at your own risk)

Hey everyone! :waving_hand:

I’ve been programming and scripting in Luau for about 18 months now. Recently, I decided I wanted to build my own StateTracker rather than relying on an existing package. My main goal was to get a deeper understanding of reactive state, generics, and client/server synchronization.

After working on it over the past couple days, I’ve gotten it to a point where I am very happy with it and it fits my use cases perfectly. I wanted to share it with the community for anyone who might find it useful, and to get some feedback!

:light_bulb: What is it?

The StateTracker allows you to create strongly typed state dictionaries that can be subscribed to by a specific key. When a value changes on the server, the change is effortlessly replicated to the client, and any connected callbacks instantly react to that change.

:warning: Full Transparency

This is designed to be a lightweight state tracker for simple, keyed state. It works beautifully for primitive values (numbers, strings, booleans) and flat objects.

However, I’m still thinking through the edge cases of deep-mutations and heavily nested tables. I’m intentionally keeping it simple for now, and I would love to hear your feedback or suggestions on how to harden it for nested state!

:open_book: How to Use It

I designed this to be very beginner-friendly. The server acts as the “source of truth”, while the client reads the state and listens for updates.

1. Server Setup (Creating and Updating State)

On the server, you initialize your state with an ID and a default table. From there, you can use built-in methods like :set(), :increment(), or :toggle() to manipulate the data.

local StateTracker = require(path.to.StateTracker)

-- Create a new state tracker with the ID "GameState"
local gameState = StateTracker.server.new("GameState", {
    RoundActive = false,
    Coins = 0,
    Status = "Waiting for players..."
})

-- Easily set new values. This automatically replicates to clients!
gameState:set("Status", "Starting soon!")

-- Toggle booleans without needing to read them first
gameState:toggle("RoundActive")

-- Increment numbers easily
gameState:increment("Coins", 50)

2. Client Setup (Listening for Changes)

On the client, you connect to the same ID. You can then use :subscribe() to fire a function every time a specific value updates, or :changed() if you need to compare the old value to the new one.

local StateTracker = require(path.to.StateTracker)

-- Connect to the existing state tracker
local gameState = StateTracker.client.new("GameState")

-- Run code whenever "Coins" changes
gameState:subscribe("Coins", function(newCoins)
    print("Player now has " .. newCoins .. " coins!")
    -- Update your UI here!
end)

-- Run code comparing the old and new value
gameState:changed("Status", function(oldStatus, newStatus)
    print("Status changed from: " .. oldStatus .. " -> " .. newStatus)
end)

3. Reading State Directly (Server & Client)

Sometimes you don’t need a listener; you just need to check a value exactly when a specific event happens. Both the server and client have access to :get() and :getState().

-- Get a single specific value
local currentCoins = gameState:get("Coins")
print("Current coins:", currentCoins)

-- Get a deep clone of the entire state dictionary
local allState = gameState:getState()
print("All data:", allState)

4. Unsubscribing from Changes

Memory management is important! When you call :subscribe() or :changed(), it returns a cleanup function. Simply call that returned function when you want to stop listening.

-- Save the returned function
local disconnectCoins = gameState:subscribe("Coins", function(newCoins)
    print("Coins updated!")
end)

-- Later, when the player dies or a UI closes, stop listening:
disconnectCoins()

5. Resetting and Destroying (Server)

If a round ends or a player leaves, you’ll need ways to reset your data or clean up the tracker entirely. The server has exclusive access to the :reset() and :destroy() methods.

-- Reset a specific key back to its initial starting value
gameState:reset("Coins") 

-- Or, reset the ENTIRE state back to the defaults provided in .new()
gameState:reset()

-- If you are completely done with this tracker (e.g., the player left), destroy it.
-- (Note: you can call :destroy() on the client as well to clear client listeners)
gameState:destroy()

6. Silent Updates (dontReplicate)

Sometimes you might want to update a state value on the server without broadcasting that change to all the connected clients. This is useful if you are updating a value multiple times in a single frame and only want to replicate the final result to save network bandwidth, or if a value is purely for the server’s internal logic.

Most server methods (:set(), :increment(), :toggle(), and :reset()) accept an optional dontReplicate boolean parameter at the end.

-- Increment the coins by 10, but DO NOT replicate this to the clients yet
gameState:increment("Coins", 10, true)

-- Toggle a boolean, but keep the change server-only
gameState:toggle("RoundActive", true)

:laptop: Source Code

Create a ModuleScript named StateTracker. Inside of it, place three more ModuleScripts named StateTrackerClient, StateTrackerServer, and StateTrackerUtils. Paste the code below into their respective modules.

StateTracker (Main Module)
local StateTrackerClient = require(script.StateTrackerClient)
local StateTrackerServer = require(script.StateTrackerServer)

return {
    server = StateTrackerServer,
    client = StateTrackerClient,
}
StateTrackerServer
--!strict

local RunService = game:GetService("RunService")

local StateTrackerUtils = require(script.Parent.StateTrackerUtils)

local SyncEvent, SyncFunc = StateTrackerUtils.getRemotes()

local StateTrackerServer = {}
StateTrackerServer.__index = StateTrackerServer

export type StateTrackerServer<State> = {
    id: string,
    _initialState: State,
    _state: State,

    _listeners: { [string]: { (newValue: any) -> () } },
    _changedListeners: { [string]: { (oldValue: any, newValue: any) -> () } },

    getState: (self: StateTrackerServer<State>) -> State,

    subscribe: <Value>(self: StateTrackerServer<State>, key: string, callback: (newValue: Value) -> ()) -> () -> (),
    changed: <Value>(
        self: StateTrackerServer<State>,
        key: string,
        callback: (oldValue: Value, newValue: Value) -> ()
    ) -> () -> (),
    set: <Value>(self: StateTrackerServer<State>, key: string, newValue: Value, dontReplicate: boolean?) -> (),
    get: <Value>(self: StateTrackerServer<State>, key: string) -> Value?,
    reset: (self: StateTrackerServer<State>, key: string?, dontReplicate: boolean?) -> (),
    increment: (self: StateTrackerServer<State>, key: string, amount: number) -> (),
    toggle: (self: StateTrackerServer<State>, key: string) -> (),
    destroy: (self: StateTrackerServer<State>) -> (),

    _fireListeners: <Value>(self: StateTrackerServer<State>, key: string, oldValue: Value, newValue: Value) -> (),
}

local ActiveTrackers = {} :: { [string]: StateTrackerServer<any> }

if RunService:IsServer() then
    SyncFunc.OnServerInvoke = function(_: Player, id: string)
        local tracker = ActiveTrackers[id]
        if tracker then
            return tracker:getState()
        else
            warn("StateTrackerServer: No active tracker found for ID:", id)
            return nil
        end
    end
end

function StateTrackerServer.new<State>(id: string, initialState: State): StateTrackerServer<State>
    local self = {
        id = id,
        _initialState = StateTrackerUtils.deepClone(initialState),
        _state = StateTrackerUtils.deepClone(initialState),
        _listeners = {},
        _changedListeners = {},
    }

    setmetatable(self, StateTrackerServer)

    ActiveTrackers[id] = self :: any
    return self :: any
end

function StateTrackerServer._fireListeners<State>(
    self: StateTrackerServer<State>,
    key: string,
    oldValue: State,
    newValue: State
)
    if self._listeners[key] then
        for _, callback in ipairs(self._listeners[key]) do
            task.spawn(function()
                callback(newValue)
            end)
        end
    end

    if self._changedListeners[key] then
        for _, callback in ipairs(self._changedListeners[key]) do
            task.spawn(function()
                callback(oldValue, newValue)
            end)
        end
    end

    SyncEvent:FireAllClients(self.id, key, newValue)
end

function StateTrackerServer.set<State, Value>(self: StateTrackerServer<State>, key: string, newValue: Value, dontReplicate: boolean?)
    local oldValue = (self._state :: any)[key]

    if StateTrackerUtils.deepEqual(oldValue, newValue) then
        return
    end

    if type(oldValue) ~= type(newValue) then
        warn(
            "Type mismatch when setting state for key: " .. tostring(key),
            "Old value type:",
            type(oldValue),
            "New value type:",
            type(newValue)
        )
    end

    (self._state :: any)[key] = newValue

    if dontReplicate then
        return
    end

    self:_fireListeners(key, oldValue, newValue)
end

function StateTrackerServer.getState<State>(self: StateTrackerServer<State>): State
    return StateTrackerUtils.deepClone(self._state)
end

function StateTrackerServer.get<State, Value>(self: StateTrackerServer<State>, key: string): Value?
    return (self._state :: any)[key]
end

function StateTrackerServer.subscribe<State, Value>(
    self: StateTrackerServer<State>,
    key: string,
    callback: (newValue: Value) -> ()
): () -> ()
    self._listeners[key] = self._listeners[key] or {}
    table.insert(self._listeners[key], callback) --> type cast :: any to callback if needed

    return function()
        if self._listeners[key] then
            for i, cb in ipairs(self._listeners[key]) do
                if cb == callback then
                    table.remove(self._listeners[key], i)
                    break
                end
            end
        end
    end
end

function StateTrackerServer.changed<State, Value>(
    self: StateTrackerServer<State>,
    key: string,
    callback: (oldValue: Value, newValue: Value) -> ()
): () -> ()
    self._changedListeners[key] = self._changedListeners[key] or {}
    table.insert(self._changedListeners[key], callback)

    return function()
        if self._changedListeners[key] then
            for i, cb in ipairs(self._changedListeners[key]) do
                if cb == callback then
                    table.remove(self._changedListeners[key], i)
                    break
                end
            end
        end
    end
end

function StateTrackerServer.increment<State>(
    self: StateTrackerServer<State>,
    key: string,
    amount: number,
    dontReplicate: boolean?
)
    local currentValue = (self._state :: any)[key]

    if type(currentValue) ~= "number" then
        warn(
            "Attempted to increment a state that is not a number: " .. tostring(key),
            "Current value:",
            tostring(currentValue)
        )
        return
    end

    self:set(key, currentValue + amount, dontReplicate)
end

function StateTrackerServer.toggle<State>(self: StateTrackerServer<State>, key: string, dontReplicate: boolean?)
    local currentValue = (self._state :: any)[key]

    if type(currentValue) ~= "boolean" then
        warn(
            "Attempted to toggle a state that is not a boolean: " .. tostring(key),
            "Current value:",
            tostring(currentValue)
        )
        return
    end

    self:set(key, not currentValue, dontReplicate)
end

function StateTrackerServer.reset<State>(self: StateTrackerServer<State>, key: string?, dontReplicate: boolean?)
    if key then
        self:set(key, StateTrackerUtils.deepClone((self._initialState :: any)[key]), dontReplicate)
        return
    end

    for k, _ in pairs(self._state) do
        if (self._initialState :: any)[k] == nil then
            (self._state :: any)[k] = nil
        end
    end

    for k, v in pairs(self._initialState) do
        self:set(k :: any, StateTrackerUtils.deepClone(v :: any), dontReplicate)
    end
end

function StateTrackerServer.destroy<State>(self: StateTrackerServer<State>)
    table.clear(self._listeners)
    table.clear(self._changedListeners)
    ActiveTrackers[self.id] = nil
    self._state = {} :: any
end

return StateTrackerServer
StateTrackerClient
--!strict

local RunService = game:GetService("RunService")

local StateTrackerUtils = require(script.Parent.StateTrackerUtils)

local StateTrackerClient = {}
StateTrackerClient.__index = StateTrackerClient

export type StateTrackerClient<State> = {
    id: string,
    _state: State,
    _listeners: { [string]: { (oldValue: any, newValue: any) -> () } },
    _changedListeners: { [string]: { (oldValue: any, newValue: any) -> () } },

    getState: (self: StateTrackerClient<State>) -> State,
    subscribe: <Value>(
        self: StateTrackerClient<State>,
        key: string,
        callback: (newValue: Value) -> ()
    ) -> () -> (),
    get: <Value>(self: StateTrackerClient<State>, key: string) -> Value,
    changed: <Value>(
        self: StateTrackerClient<State>,
        key: string,
        callback: (oldValue: Value, newValue: Value) -> ()
    ) -> () -> (),

    _networkUpdate: <Value>(self: StateTrackerClient<State>, key: string, newValue: Value) -> (),
}

local SyncEvent, SyncFunc = StateTrackerUtils.getRemotes()

local ActiveTrackers = {} :: { [string]: StateTrackerClient<any> }

if RunService:IsClient() then
    SyncEvent.OnClientEvent:Connect(function(id: string, key: string, newValue)
        if ActiveTrackers[id] then
            ActiveTrackers[id]:_networkUpdate(key, newValue)
        end
    end)
end

function StateTrackerClient.new<State>(id: string): StateTrackerClient<State>
    local self = {
        id = id,
        _state = {},
        _listeners = {},
        _changedListeners = {},
    }

    local initialState = SyncFunc:InvokeServer(id)
    if initialState == nil then
        warn("StateTrackerClient: tracker not ready yet for id:", id)
        return nil :: any
    end

    self._state = StateTrackerUtils.deepClone(initialState)

    setmetatable(self, StateTrackerClient)

    ActiveTrackers[id] = self :: any

    return self :: any
end

function StateTrackerClient.getState<State>(self: StateTrackerClient<State>): State
    return StateTrackerUtils.deepClone(self._state)
end

function StateTrackerClient.get<State, Value>(self: StateTrackerClient<State>, key: string): Value
    return (self._state :: any)[key]
end

function StateTrackerClient.subscribe<State, Value>(
    self: StateTrackerClient<State>,
    key: string,
    callback: (newValue: Value) -> ()
): () -> ()
    self._listeners[key] = self._listeners[key] or {}
    table.insert(self._listeners[key], callback :: any)

    return function()
        if self._listeners[key] then
            for i, cb in ipairs(self._listeners[key]) do
                if cb == callback :: any then
                    table.remove(self._listeners[key], i)
                    break
                end
            end
        end
    end
end

function StateTrackerClient.changed<State, Value>(
    self: StateTrackerClient<State>,
    key: string,
    callback: (oldValue: Value, newValue: Value) -> ()
): () -> ()
    self._changedListeners[key] = self._changedListeners[key] or {}
    table.insert(self._changedListeners[key], callback :: any)

    return function()
        if self._changedListeners[key] then
            for i, cb in ipairs(self._changedListeners[key]) do
                if cb == callback :: any then
                    table.remove(self._changedListeners[key], i)
                    break
                end
            end
        end
    end
end

function StateTrackerClient._networkUpdate<State>(self: StateTrackerClient<State>, key: string, newValue: any)
    local oldValue = (self._state :: any)[key]
    if oldValue == newValue then
        return
    end

    (self._state :: any)[key] = newValue

    if self._listeners[key] then
        for _, callback in ipairs(self._listeners[key]) do
            task.spawn(function()
                callback(newValue)
            end)
        end
    end

    if self._changedListeners[key] then
        for _, callback in ipairs(self._changedListeners[key]) do
            task.spawn(function()
                callback(oldValue, newValue)
            end)
        end
    end
end

function StateTrackerClient.destroy<State>(self: StateTrackerClient<State>)
    table.clear(self._listeners)
    table.clear(self._changedListeners)
    ActiveTrackers[self.id] = nil
    self._state = {} :: any
end

return StateTrackerClient
StateTrackerUtils
local RunService = game:GetService("RunService")

local REMOTE_FOLDER_NAME = "_remotes"
local REMOTE_EVENT_NAME = "SyncEvent"
local REMOTE_FUNCTION_NAME = "SyncFunc"

local remoteFolder
local remoteEvent
local remoteFunction

if RunService:IsServer() then
    remoteFolder = Instance.new("Folder")
    remoteFolder.Name = REMOTE_FOLDER_NAME
    remoteFolder.Parent = script

    remoteEvent = Instance.new("RemoteEvent")
    remoteEvent.Name = REMOTE_EVENT_NAME
    remoteEvent.Parent = remoteFolder

    remoteFunction = Instance.new("RemoteFunction")
    remoteFunction.Name = REMOTE_FUNCTION_NAME
    remoteFunction.Parent = remoteFolder
else
    remoteFolder = script:WaitForChild(REMOTE_FOLDER_NAME, 5)
    remoteEvent = remoteFolder:WaitForChild(REMOTE_EVENT_NAME, 5)
    remoteFunction = remoteFolder:WaitForChild(REMOTE_FUNCTION_NAME, 5)
end

local StateTrackerUtils = {}

function StateTrackerUtils.getRemotes(): (RemoteEvent, RemoteFunction)
    return remoteEvent, remoteFunction
end

function StateTrackerUtils.deepClone(value)
    if typeof(value) ~= "table" then
        return value
    end

    local out = {}
    for k, v in pairs(value) do
        out[k] = StateTrackerUtils.deepClone(v :: any)
    end
    return out
end

function StateTrackerUtils.deepEqual(a, b)
    if a == b then
        return true
    end

    local aType = typeof(a)
    local bType = typeof(b)

    if aType ~= "table" or bType ~= "table" then
        return false
    end

    for k, v in pairs(a) do
        if not StateTrackerUtils.deepEqual(v :: any, b[k]) then
            return false
        end
    end

    for k, _ in pairs(b) do
        if a[k] == nil then
            return false
        end
    end

    return true
end

return StateTrackerUtils
2 Likes