How to make an "Observer" Class

Hey, hey, hey!

The Observer Class allows you to track state changes and notifications.
(get it? because it observes?)

Dependencies

  • A Signal module
  • Knowledge of how object-oriented programming works.

Let’s jump straight into creating one:

You should start your first lines with something like this.

--!strict

-- observer.luau

local SignalPlus = require("../Dependencies/SignalPlus")

--[[
	Observer class to handle state changes and notifications
]]
local Observer = {}
Observer.__index = Observer

And before we forget, your API/types should look something like this:

export type Observer<T> = {
	_value: T,
	Changed: SignalPlus.Signal<(newValue: T, oldValue: T) -> ()>,
	_connections: { SignalPlus.Connection }?,
	Get: (self: Observer<T>) -> T,
	Set: (self: Observer<T>, value: T) -> (),
	Watch: (self: Observer<T>, callback: (newValue: T, oldValue: T) -> ()) -> SignalPlus.Connection,
	Map: (self: Observer<T>, fn: (current: T) -> T) -> (),
	Destroy: (self: Observer<T>) -> (),}
}

export type observer_mt<T> = setmetatable<Observer<T>, typeof(Observer)>

Next, create a constructor:

--[[
    ```luau

	 local Observer = require(path.to.observer)
    -- Create a new observer with an initial value
    local counter = Observer.new(0)
    ```
]]
function Observer.new<T>(initialValue: T): observer_mt<T>
	return setmetatable({
		_value = initialValue,
		Changed = SignalPlus() :: SignalPlus.Signal<(newValue: T, oldValue: T) -> ()>,
		_connections = {} :: { SignalPlus.Connection },
	}, Observer)
end

Create a method for accessing the internal value:

--[[
    ```luau

    -- Access the current value
    local currentValue = counter:Get()
    print("Current value:", currentValue)
    ```
]]
function Observer:Get<T>(): T
	return self._value
end

Continue with the most important method here: setting the value.

--[[
    ```luau
	 
    -- Set a new value
    counter:Set(5)
    print("New value set to: ", counter:Get())
    ```
]]
function Observer:Set<T>(newValue: T)
	if self._value == newValue then
		return
	end

	local old_value = self._value
	self._value = newValue

	self.Changed:Fire(newValue, old_value)
end

After creating the most important method, create a :Watch method to watch the value.

--[[
    ```luau

    -- Watch for changes
    local connection = counter:Watch(function(newValue, oldValue)
        print("Counter changed from", oldValue, "to", newValue)
    end)
    ```
]]
function Observer:Watch<T>(callback: (newValue: T, oldValue: T) -> ()): SignalPlus.Connection
	if not self._connections then
		self._connections = {}
	end

	local conn = self.Changed:Connect(callback)

	table.insert(self._connections, conn)

	return conn
end

Alternatively, you could utilize the .Changed event like this:

counter.Changed:Connect(function(new)
  print("Counter changed to" .. tostring(new))
end)

To finalize your module, top it all off with a:

function Observer:Destroy()
	self.Changed:Destroy()

	table.clear(self)
	setmetatable(self, nil)
end

Don’t forget to return your module.

return Observer

With all that, you could add your own other stuff like:

--[[
	Calculate a new value based on the current value and a function.

	```luau
	local observer = Observer.new(10)
	observer:Calculate(function(current)
		return current * 2
	end)
	-- observer:Get() is now 20
	```
]]
function Observer:Map<T>(fn: (current: T) -> T)
	local newValue = fn(self:Get())

	self:Set(newValue)
end

and

--[[
	```luau
	local none = observer.None

	-- An observer with no value.

	none:Expect("nil value")
]]

Observer.None =
	table.freeze(setmetatable({ _value = nil, Changed = SignalPlus() }, Observer)) :: observer_mt<any?>

or

--[[
	```luau
	local observer = observer.None

	local expected = observer:Expect()
	-- expects a value from the observer.
	-- if no value, it throws an error.
]]
function Observer:Expect<T>(str: string): T
	return assert(self:Get(), str or "Expected a value from observer.")
	--return if self:Get() ~= nil then self:Get() else error(str or "Expected a value from observer.")
end

Thanks for listening in, and kindly tell me if there are any inconsistencies to be modified or any improvements to be made.

4 Likes

Don’t know what this even does, useless bloat

4 Likes

This is not true; observers are core logic in other reactive extensions in other languages, and they manage states. This allows you to subscribe to when a value changes, and in this context, it also allows you to define the types involved with observing those values. This is not useless bloat, as when working with large databases, race conditions and state management get harder and harder, and this makes it 10x easier

1 Like

You wanna know what else allows you to subscribe to when a value changes, especially without unnecessary bloat?

Yeah, it’s called just calling a function when you change the value. Or; just use a setter function.

1 Like