Values - a simple way to create interdependent variables which offer consistent updates

Recently, I’ve done some research on Fusion and I was interested in the Computed, Observer and Value implementations, though i believed they were too ‘in the way’ and were unnecessarily bloated. So, i decided to make a quick and simple module which demonstrates a way in which I believe is better for this type of logic to be handled, this way is more flexible and easier to understand.

local Values = require(game.ReplicatedStorage.Values);

local Health = Values.new(100);

Health.Changed:Connect(function(newValue: number) 
	print('Your health is now:', newValue)	
end)

Health.Value = 10; --> your health is now: 10

local TotalApples = Values.new(10);
local ApplesEaten = Values.new(0);
local ApplesLeft = TotalApples - ApplesEaten;

ApplesLeft.Changed:Connect(function(newValue: number) 
	print('You have '.. tostring(newValue).. ' apples left.')	
end)

ApplesEaten.Value += 1; --> 9 apples left
ApplesEaten.Value += 1; --> 8 apples left
TotalApples.Value -= 1; --> 7 apples left
ApplesLeft.Value -= 1; --> errors because we cannot set the value of a value which depends on different values

local Map = Values.new() --> keep map unassigned
local Spawnpoints = Map.Spawnpoints --> a reference to a member 'Spawnpoints' (currently just nil)
print(Spawnpoints.Value) --> nil, can be seen with this line.
Map.Value = NewMap; --> updates all dependent nodes
print(Spawnpoints.Value) --> Spawnpoints instance under NewMap

Have fun! I’ve attempted to type it as much as possible but i do not think it is possible to type references (such as the last example) consistently.
Get the module here

16 Likes

Newest release supports function calls to value references!
For example:

local PlayerLookVector = Value.new(Vector3.zero)
local EnemyLookVector = Value.new(Vector3.zero)
local DotProduct = PlayerLookVector:Dot(EnemyLookVector)

--DotProduct.Value, DotProduct.Changed all update accordingly!

some other typing improvements have also been made alongside this feature
also, additional improvements have been made values now store their resolved data in a cache until their dependencies have invalidated the cached data.

If you guys have any other requests to make this module more versatile, let me know!

Nice dude! This seems like a more convenient approach to be honest.

Just a question, does this module compared to its inspirations function the same?

I created something similar here - GitHub - cosinewaves/beta: Beta is a tiny, type-safe, and reactive utility library inspired by Fusion and functional UI paradigms. It lets you build reactive state, derived values, and respond to changes cleanly.

I do not think it works with Fusion natively, but overall it functions the same, i’m sure you can also just reference the values made by my system in fusion’s systems but it’s not really convenient, so it wont work with fusion but overall it functions the same (or, in some cases, better and more flexible than) the system offered by Fusion.

looks very similar! I didn’t know this existed, the underlying functionality is the same / similar as mine (there isnt many other ways to implement this type of system) but the syntax is different - it’s honestly just preference, and i feel this syntax works better than one which requires constant function definitions (in your system, evaluate, in Fusion, the computeds).

Fixed some bugs regarding changing event firing when the value was not changed, added a few new functionalities to allow for more complex statements to be written without the need of use of the Changed event:
New added functions

(Values.GreaterThan, Values.ge, Values.greaterThan)(value0, value1) --> returns a value class which will appropriately update as value0 and value1 vary
(Values.LessThan, Values.le, Values.lessThan)(value0, value1) --> returns a value class which will appropriately update as value0 and value1 vary
(Values.Equals, Values.eq, Values.equals)(value0, value1) --> returns a value class which will appropriately update as value0 and value1 vary
(Values.LessThanOrEqual, Values.le, Values.lessThanOrEqual)(value0, value1) --> returns a value class which will appropriately update as value0 and value1 vary
(Values.GreaterThanOrEqual, Values.ge, Values.greaterThanOrEqual)(value0, value1) --> returns a value class which will appropriately update as value0 and value1 vary
Values.Not(value0) --> returns the truthy not of the value given by value0

this allows you to create chains of logic, hence offering two ways to create logic with this system now:

local Values = require(workspace.Values:Clone())
local TimeNow = Values.new(os.clock());
local CooldownStarted = Values.new(TimeNow.Value)
local CooldownLength = Values.new(0.5)
local IsOnCooldown = Values.lt(TimeNow - CooldownStarted, CooldownLength)

IsOnCooldown.Changed:Connect(function(newValue) 
	print('Cooldown state has changed!', newValue)	
end)

task.spawn(function()
	while task.wait() do
		TimeNow.Value = os.clock() --> for demonstration purposes, don't actually do this in code.
	end
end)

print(IsOnCooldown.Value)

The old way you’d do this is by using the 2nd and other args of Values.new, for example in the code above, the line: local IsOnCooldown = Values.lt(TimeNow - CooldownStarted, CooldownLength) would be replaced with the lines:

local IsOnCooldown = Values.new(false, function() --> the initial state here does not matter.
       return TimeNow.Value - CooldownStarted.Value <= CooldownLength.Value
end, TimeNow, CooldownStarted, CooldownLength) --> you'd have to specify the dependencies like this

Hopefully this should help make coding with the module more flexible and easier!

By the way, if you were to actually make a cooldown system using this, you’d want to update TimeNow.Value = os.clock() where you plan to check the cooldown, this is more optimised, in the future i might add a way for active values (values which are considered to be ever-changing hence caching is disabled for them) which would greatly simplify this logic to be something along the lines of:

-- this is a proposal, this code wont work (atleast i dont think so)
local os = Values.new(os)
local IsOnCooldown = Values.lt(os.clock() - CooldownStarted, CooldownLength)
IsOnCooldown.Value --> automatically evaluate at index-time the os.clock 

this’d probably work by making function calls be considered as uncacheable, and hence their dependencies would have to evaluate them when their Value is fetched.

Fixed a few bugs to do with indexing, this module will be slightly better maintained since now I am using it to handle data for one of my games.
Some tips / important notes while using this module:

--> since all values are unresolved until .Value is called, we need to replace the 
--> conventional tbl[i] = tbl[i] or v with:
--> tbl[i] = tbl[i].Value or v;
--> heres an example from my game:

local PlayerData = DataLoader:Get(player)
PlayerData.PlotData[ItemName] = PlayerData.PlotData[ItemName].Value or {};
PlayerData.PlotData[ItemName].CFrame = PlayerData.PlotData[ItemName].CFrame.Value or {};

some changes to behaviour has come with this update:
→ the .Changed signal will update once per task.defer cycle and will now run deferred, this may impact some code bases but overall behaviour should be stable.
→ before: val[i] != val[i] but i’ve realised this was a bad way to do this and have fixed this behaviour and memory usage is better now.
→ before: val[i].Value = x this was mandatory syntax, but now you can simplify it down to val[i] = x
thats all, if you encounter any additional bugs please let me know, since these will be present in my game too it is my utmost priority to fix them!

additional changes!
→ removed a random debug print which was left in the old version
→ fixed a behaviour where indexing a new key for the first time would also fire its Changed signal which could be listened to (unintended) .

fixed a bug where non-table values would fire .Changed with their previous value, it should work nicely now.
fixed a weird error (i dont think it caused bugs but it is fixed)
Heres also an interesting feature which i’ve found:

local Stocks = Values.new({})
Stocks.Door.Changed:Connect(function(newValue)
      print('stock udpated to:',newValue)
end)
Stocks.Door = 2 --> updates the connection, this can be extrapolated to a larger scale:

Stocks.Shared.Table.Changed:Connect(print)
Stocks.Shared = Stocks.Shared.Value or {}
Stocks.Shared.Table = Stocks.Shared.Table.Value or 1; --> prints 1
--> meaning we can bind to variables which WE KNOW will exist but arent yet initialised.

Remember that I do check this thread every day so if you encounter any bugs or have any improvements in mind, I’d love to know.

New update introduces extended functions to values & lazy init:
Lazy Init:

val = Value.new({test = 1})
val.test2.abc.a = 1; --> will create nested tables all the way to "a".

Extended functions:

val:Remove(index : number)
val:SwapRemove(index : number)
val:Swap(index0 : number, index1 : number)
val:Insert(index : any?, value : any?) --> same as table.insert
val:Clear()
val:Unpack()
val:Sort((a,b) -> boolean) --> same as table.sort
val:Find(value : any, init : number) : number?
val:Reconcile(tbl2 : any)
val:Overwrite(tbl2 : any) --> will overwrite the different values
val:DeepEqual(tbl2)
val:DeepCopy()

also added support for iteration:

for k, v in val do
--> v is a values object, so you can do v.Value = k to set it or you can do v.Changed:Connect ... the usual
end

fixed a small bug with .Changed not updating upon calling .Value = on it (oops)

Your module link is broke I believe.

Values.rbxm (8.5 KB)
Here’s a rbxm of the module

1 Like

Link is currently down, maybe it got moderated?
Just upload a rbxm

i already contacted roblox, i think they dont like that for the signal library i was using getfenv to allow the bound connections to fully print the traceback but i’ll remove it and it should be good hopefully

1 Like

this is what I was advised to do for my fork of signal+:

Signal.Fire = function(signal, ...)
	local connection = signal.Next
	local fireTraceback = debug.traceback("Signal fired from:", 2)
	local errors = {}
	
	local function errorHandler(err)
		local handlerTraceback = debug.traceback(tostring(err), 2)
		local fullErrorMessage = string.format(
			"%s\n%s",
			handlerTraceback,
			fireTraceback
		)
		return fullErrorMessage
	end
	
	local function handler( ...)
		--print(fireTraceback, ...)
		local success, result = xpcall(connection.Callback, errorHandler, ...)
		if not success then table.insert(errors, result) end
	end
	
	while connection do
		-- Find or create a thread, and run the callback in it.
		local length = #threads
		if length == 0 then
			local thread = coroutine.create(reusableThread)
			coroutine.resume(thread)
			
			task.spawn(thread, handler, thread, ...)
		else
			local thread = threads[length]
			threads[length] = nil -- Remove from free threads list.
			task.spawn(thread, handler, thread, ...)
		end
		
		-- Traverse.
		connection = connection.Next
	end
	if #errors > 0 then error(errors[1], 0) end
end

I think you can make this pattern more robust and prevalent if that helps!

Do you think you could make it strongly typed in !strict?