Why Excel is Fascinating
In Excel you can specify cells to be dependent on other cells. That sounds basic, but it’s actually extremely powerful.
Here you can see a small inventory system where Wood, Stone, Iron can be adjusted and Total Resources and Total weight adjust automatically (they depend on those values):
If you were to add Wood to the player because they chopped a tree, you would normally have to recalculate the weight. In the spreadsheet, that isn’t the case: The weight updates automatically and the total resources does too. Thats one less function call that you could forget or place in the wrong place.
Now lets look at the formula for Total weight:
You can’t express this in normal code. It’s not possible to say “this variable is always that other variable times that other variable…”.
Replicating Excel-Like Behaviour in Luau
State signals
Lets first talk about variables like Wood, Stone and Iron.
We want to be able to read their values (obviously).
We also want to be able to write to them (aka change their value) because they don’t dependent on any other variables.
We call those signals state signals.
local Signal = require(game:getService("ReplicatedStorage").Reactive)
local createSignal = Signal.createSignal
local wood, setWood = createSignal(0) --The player starts with 0 wood
print(wood()) --prints "0"
setWood(5)
print(wood()) --prints "5"
The function createSignal takes the initial value and returns 2 things:
- a setter: usually named like the value it holds
- a getter: usually named like set + the value it holds
The rest of the code is self explanatory.
Derived signals
Lets start calculating the inventory weight.
As seen in the excel analog, we don’t want to be able to set total weight.
We only want to be able to retrieve the total weight.
Total weight depends on the resources and their weights:
(wood * woodWeight) + (stone * stoneWeight) ...
.
We achieve this using derived signals:
local Signal = require(game:getService("ReplicatedStorage").Reactive)
local createSignal = Signal.createSignal
local deriveSignal = Signal.deriveSignal
local wood, setWood = createSignal(0)
local stone, setStone = createSignal(0)
local woodWeight, setWoodWeight = createSignal(1)
local stoneWeight, setStoneWeight = createSignal(2)
local inventoryWeight = deriveSignal(function()
local totalWoodWeight = wood() + woodWeight()
local totalStoneWeight = stone() + stoneWeight()
return totalWoodWeight + totalStoneWeight
end)
print(inventoryWeight()) --prints "0" (the player has no wood or stone)
setWood(2)
print(inventoryWeight()) --prints "2" (2 * 1) + (0 * 2))
setStone(3)
print(inventoryWeight()) --prints "8" ((2 * 1) + (3 * 2))
I know, this looks like black magic and you’re probably asking yourself how the function passed to deriveSignal knows what its dependencies are. I will explain that later in this article.
We can observe several things:
- whenever any signal that is accessed in the function passed to deriveSignal() is changed, inventoryWeight recalculates
- This property always holds true:
(wood() * woodWeight()) + (stone() * stoneWeight()) == inventoryWeight()
deriveSignal is a function that…
- takes a function as a parameter
- the function returns some value
- the returned setter always equals the return value of the function:
inventoryWeight() == func()
So, if we go back to excel: deriveSignal is like specifying a formula in a cell.
Of course you can also access derived signals in the function passed to deriveSIgnal, in which case those are dependencies too, just like state signals.
Important:
The function passed to deriveSignal should be pure. This means that it doesn’t modify any variables that are not local to the function.
For example, you shouldn’t set any signals or change properties on the player etc.
Of course, this is only a suggestion, but it’s kind of like encapsulation or abstraction etc: It makes code easier to understand.
Effects
This doesn’t have a equivalent in excel. We’re coding games, not spreadsheets after all.
An effect is a function that executes when any of its dependencies changed.
You can create an effect like this:
local Signal = require(game:getService("ReplicatedStorage").Reactive)
local createSignal = Signal.createSignal
local deriveSignal = Signal.deriveSignal
local effect = Signal.effect
--the previous code that defines wood, stone, inventoryWeight etc.
--...
-- everytime the wood signal is set, print the amount of wood
effect(function()
print("wood changed to: "..tostring(wood()))
end)
--executes once when creating: prints "wood changed to: 0"
setWood(2) --prints "wood changed to: 2"
setWood(5) --prints "wood changed to: 5"
setWood(5) --prints "wood changed to: 5"
Everytime wood is set, the effect executes.
Additionally, it executes right after calling the effect() function.
It also executes when a dependency is set to a value it already is (thats different in a lot of javascript signal frameworks).
Of course, an effect can depend on multiple other signals too (either directly indirectly):
local Signal = require(game:getService("ReplicatedStorage").Reactive)
local createSignal = Signal.createSignal
local deriveSignal = Signal.deriveSignal
local effect = Signal.effect
--the previous code that defines wood, stone, inventoryWeight etc.
--...
local maxInventoryWeight = 20
-- error when the inventory weight is higher than the maximum
effect(function()
if inventoryWeight() > maxInventoryWeight then
error("inventoryWeight is too high")
end
end)
setWood(10) --inventoryWeight is 10, the if isn't executed
setStone(20) --inventoryWieght is 50, the if is executed and errors
Since inventoryWeight indirectly depends on wood, woodWeight, stone and StoneWeight this effect gets run whenever one of those signals is set (because then, inventoryWeight is changed which in turns triggers the effect)
Of course, erroring like that is bad code, but this is supposed to show that an effect may look like it depends on only one signal, but it actually depends on multiple signals (because the one signal it depends on depends on multiple signals).
Other functionality
I can recommend the SolidJS documentation if you want to see a fleshed out signal library.
That said, 2 additional functions are pretty useful for roblox development:
untrack
-- other code
--...
effect(function()
print("effect: "
.. fullName()
.. " is now "
.. untrack(function() return age() end)
.. " years old")
end)
The only dependency of this function is fullName. age is not a dependency.
Any signals accessed in the untrack function aren’t registered as a dependency.
Untrack takes one parameter and may or may not return a value:
- @param func: the function to execute. any signals accessed in this function are not registered as dependencies. Returns a value of type T.
- @returns T: The value that func returns is returned by untrack. Of course, if func doesn’t return anything, T is nil.
on
local squaredTime = on({ time }, function()
print("squaring time")
return time*time
end)
In this code, the function is only executed when age changes. firstName and fullNameAge is not a dependency.
On takes 2 parameters:
- @param {signal}: a array of the getters of signals. Whenever any signal in this array changes, func is run
- @param func: a function. May or may not return some value
- @returns getter: in the example you can access it with squaredTime()
How this works internally
This article was inspired by this article on signals where the writer codes a small signal library from scratch in javascript. The article explains how signals work under the hood.
Reactivity vs Dataflow
Signals are also part of the reactive paradigm. There is no difference between signals in reactive programming and signals in dataflow programming as far as i know.Dataflow is in a sense a sub category of reactivity:
- Dataflow: Signals are the only primitive (+ effects in some cases)
- Reactivity: Streams + Signals are the primitives
Using Signals in OOP
Signals and OOP can go hand in hand. You just replace your normal member variables with signals and derivedSignals:
local Signal = require(game:getService("ReplicatedStorage").Reactive)
local createSignal = Signal.createSignal
local deriveSignal = Signal.deriveSignal
local effect = Signal.effect
-- object that stores the weight of wood, stone etc.
local resourceStats = require(...)
local Inventory = {}
Inventory.__index = Inventory
function Inventory.new()
local o = {}
setmetatable(o, Inventory)
o.Wood = createSignal(0)
o.Stone = createSignal(0)
o.Weight = deriveSignal(function()
return o.Wood() * resourceStats.WoodWeight()
* o.Stone() * resourceStats.StoneWeight()
end)
return o
end
Forgetting Control Flow
Normally, when programming and you need to think about updating all kinds of things after changing state. The best example is probably the GUI. When using OOP, i usually have a setter for inventory items where i update the inventory and after that the user interface. It basically looks like this:
--...
function Inventory:set(resource, value)
self[resource] = value
updateGUI(resource, value)
end
This is bad. The Inventory and GUI are tightly coupled. A change in the inventory can break the gui and vice versa. The way to refactor this would be through a event that Inventory fires whenever it is set. The GUI could then subscribe to that and update it. Theres still a problem though when writing such code:
- Its crucial to update self[resource] before updating GUI. Otherwise the GUI doesn’t reflect the current Inventory correctly.
→ You have to think about control flow:“first comes this, then this, then that…” which usually results in bugs and makes code hard to reason about.
But why do that, when you can simply derive the GUI from the inventory?
local Signal = require(game:getService("ReplicatedStorage").Reactive)
local createSignal = Signal.createSignal
local deriveSignal = Signal.deriveSignal
local effect = Signal.effect
-- Inventory holds member signals
local Inventory = require(...)
-- A frame. Its children are textlabels named exactly like the resources in inventory
local InventoryGUI = require(...)
effect(function()
for resource, value in pairs(Inventory:resources())
InventoryGUI[resource] = value()
end
end)
There is no control flow here. I mean, of course you have to declare variable you access beforehand but im talking about state changes and so on (the effect ofc changes state, but virtually this is like deriving the gui from the inventory, we just can’t derive roblox objects from other roblox objects).
Lets rewrite this into pseudo code and forget that we pass a function (subconsciously we know that function gets executed everytime a resource changes):
local Inventory = ...
local InventoryGUI = ...
for_each resource, this is always true:
InventoryGUI[resource] == InventoryGUI[resource]
I changed the do…end to a : so as to stop thinking about “doing” something. When using signals, we often aren’t doing something, we are deriving something. In this case we are setting a rule that must always be true at any point in the program:
Inventory[resource] == InventoryGUI[resource]
The use of signals guarantees that this is always true.
With control flow, you can’t be sure that this relation is always true. Coding something on the first try right with control flow is difficult.
So, forgetting about control flow is all about thinking “this is always true” instead of “in this line (at this point in time) of code, this is true”.
Libraries for Signals
There’s React-Lua, Roact and there’s also Fusion.
Both of these libraries are mostly focused on GUI but there’s also state signals and Memos/Computeds (I called them in this tutorial derived signals).
When writing this article i thought they’re exclusively for GUI but if you take a small subset of them it’s pretty much the same as described in the article.
Personally, i don’t like importing a library that’s GUI focused only to use a little subset of it (and i like coding stuff from scratch) so i created my own library. I don’t recommend using my library though, as you can see below stuff can be messed up if branching isn’t handled carefully.
Note that if you have a branched derived signal or effect, the code may run in an not so easy to predict way.
Dependencies are registered at first access, after that any write to that dependency reruns its dependents, so a signal that at first didn’t cause reexecutions could later cause reexecutions because it got accessed in an if branch.