Creating & Mapping State
At its heart, Dex is also a language for representing State. State is any hidden variable that, when changed, will eventually lead to something updating in a User Interface. State can be things like coins, whether or not a player is hovering/pressing over a UI component, the price/display name/thumbnail of an item being sold to the user, and more. Creating reactive UI Components in Dex requires breaking down what simple variables are being displayed to the user on-screen.
In the last section, we showed an example of a shop menu which appears when a player interacts with an NPC:
We can break this UI down into States by thinking about what hidden variables are being conveyed to the user. Here's a breakdown of what variables some of these UI components are using:
ShopHeader
:
local title: string -- The display name of the NPC's shop
CoinCounter
:
local coins: number -- How many coins the player currently has
ShopItem
:
local id: string -- What's the ID of the item we're showing?
local thumbnail: string -- What's the ID of the image thumbnail we're showing?
local displayName: string -- e.g. "Flintlock", "Sword"
local price: number -- How many coins does this cost?
Let's focus in on the CoinCounter
Component that was covered in the last
section, and let's represent this "coins" variable as a state.
CoinCounter
Component
We can create a State that holds a number representing the player's coins by calling the function Dex.State:
local coins = Dex.State(0)
Then, we can use :Set()
to update this state and :Current()
to get its
current value:
print(coins:Current()) -- 0
coins:Set(42)
print(coins:Current()) -- 42
Let's now utilize this state in our UI.
So far, we've seen examples of using VirtualInstances to assign static properties of an instance:
local coinsLabel = Dex.Premade("TextLabel", {
Text = "42",
})
Dex also supports passing in States to the property table of a virtual instance. Doing so will cause the UI to automatically update whenever this state changes!
local coinsLabel = Dex.Premade("TextLabel", {
Text = coins,
})
The way in which Dex Components generate visuals from State follows a software paradigm called Reactive Programming. To gain a better understanding of how Reactive Programming works in Dex, let's quickly go over a core concept in Dex: Observables
Observables
State actually inherits from a base class called Observable. Observables are objects that hold some value and can listen to changes in this value.
Dex.State
is a special type of Observable in that its value can be written
to; however, some observables are "read-only" and their value depends on other
factors.
Let's pass "coins" as a paramater to the CoinCounter component. "coins" is both
a State object and an Observable object; however, since we only need
to read from this state within the CoinCounter
component, we can type this
parameter as an Observable to make the component more re-usable.
local function CoinCounter(coins: Dex.Observable<number>)
return Dex.Premade("Frame", {}, {
CoinsLabel = Dex.Premade("TextLabel", {
Text = coins,
})
})
end
If we now render this component in our application, we can write some code that
- Creates a Coins state; 2) Creates a CoinCounter that reactively renders this state; and 3) updates this state over time.
-- Create a state to hold coins
local coins = Dex.State(0)
-- Render a new CoinCounter component within a Dex.Root object (presumed to
-- exist in this scope)
root:Render(CoinCounter(coins))
-- Increment the value of coins every second
while task.wait(1) do
coins:Set(coins:Current() + 1)
end
The CoinCounter
is now a fully reactive Component, as it generates visuals
based on the value of an Observable, and updates these visuals whenever
the Observable's value changes!
Mapping Observables
Right now the CoinCounter
component we created displays the raw value of
coins we have. But, let's say we wanted to add a "Pounds" symbol to the label
and two decimal points, so that 42
shows up as £42.00
. To do this, we will
need to transform the coins state somehow.
Mapping Is the process of transforming one observable to another by using a transformation function. Mapping is achieved in Dex by calling Dex.Map with the state we want to map, then calling the returned value again with a transformation function.
The Mapping syntax looks like this:
local coinsFormatted = Dex.Map(coins)(function(currentCoins)
return string.format("£%.2f", currentCoins)
end)
Here we created a an Observable string, whose value depends on the
current value of coins (an observable number). Updating the coins
state will
also update the value of coinsFormatted
:
coins:Set(123)
print(coinsFormatted:Current()) -- £123.00
Let's use a mapped value to format the coins
observable in our CoinCounter
component example:
local function CoinCounter(coins: Dex.Observable<number>)
return Dex.Premade("Frame", {}, {
CoinsLabel = Dex.Premade("TextLabel", {
Text = Dex.Map(coins)(function(currentCoins)
return string.format("£%.2f", currentCoins)
end),
})
})
end
Mapping Multiple Values
Observable Mapping can take in multiple inputs. For example, if you wanted
to derive a value from coins
and a currency
type, you would simply call
Dex.Map
with two arguments:
local coins = Dex.State(0)
local currency = Dex.State("£")
local coinsFormatted = Dex.Map(currency, coins)(function(
currentCurrency,
currentCoins
)
return string.format("%s%.2f", currentCurrency, currentCoins)
end)
print(coinsFormatted:Current()) -- £0.00
coins:Set(42)
currency:Set("$")
print(coinsFormatted:Current()) -- $42.00
Dex provides a shorthand method for mapping an single input observable to a
single output observable called :Map(). Due to a current
Luau language limitation, calling the :Map()
method will discard the type
information of the output observable, so you should prefer using Dex.Map
over Observable:Map()
in most cases for the sake of type safety.
:Map()
is still useful in situations where you do not need the type of the
output observable, such as when storing it as a VirtualInstance property:
return Dex.Premade("TextLabel", {
Text = coins:Map(function(currentCoins)
return string.format("£%.2f", currentCoins)
end),
})
Using Math Operations on Observables
This feature has been disabled due to a regression in Luau's type system.
Dex provides operator overloads for observables of the same number or vector
type! You can use operators like +
, -
, *
, /
, and ^
between two
observable objects to get a mapped observable:
local a = Dex.State(3)
local b = Dex.State(4)
local sum = a + b
print(sum:Current()) -- 7
a:Set(4)
print(sum:Current()) -- 8
In the example above, sum
is equivalent to mapping a
and b
with an
summation mapping function:
local sum = Dex.Map(a, b)(function(currentA, currentB)
return currentA + currentB
end)
You can also use math overloads on an Observable and its same value type. For
example, you can add a UDim2 with an Observable UDim2:
local basePosition: Dex.Observable<UDim2> = Dex.State(UDim2.fromScale(0.5, 0.1))
local PADDING = UDim2.fromScale(0.05, 0.05)
local paddedPosition = basePosition + PADDING
print(paddedPosition:Current()) -- ~ {0.55, 0}, {0.15, 0}
Subscribing to State
The primary feature of Observables is, of course, that they can be observed.
This is done by calling the :Subscribe()
method, which calls a listener
whenever the observable's value changes, and can be unsubscribed.
local value = Dex.State(42)
local unsubscribe = value:Subscribe(function(currentValue)
print("The current value is ", currentValue)
end)
value:Set(128) -- The current value is 128
value:Set(256) -- The current value is 256
If you directly Subscribe to Observables, make sure to always handle the returned unsubscribe function when the observable is no longer needed. More complex observables may stick around in memory until they are unsubscribed!
Observables have one caveat that they might stick around in memory as long as there is a listener subscribing to them. This is because, as we will cover in later sections, some observables like Stopwatches, Springs, etc. need to bind to Heartbeat to update their value every frame. Subscribing to an Observable for the first time may set up side effects that will only be taken down once the Observable is unsubscribed by all listeners.
A Safer Alternative to :Subscribe()
A safe alternative to calling Observable:Subscribe()
is the function
VirtualInstance:SubscribeWhileMounted()
, which takes in the Observable as a
first parameter, and automatically unsubscribes to the observable's value once
the VirtualInstance is Unmounted (i.e. is no longer being rendered by
Dex).
We can use this inside a component to safely handle side effects and debugging in a way that cleans itself up when the Component's Virtual Instances stop being rendered by Dex:
local function ComponentWithSideEffects(value: Dex.Observable<number>)
local frame = Dex.Premade("Frame")
-- This will keep printing changes to the value until the frame is no longer
-- rendered
frame:SubscribeWhileMounted(value, function(currentValue)
print("The current value is", currentValue)
end)
return frame
end
The above code sample will keep printing the value passed in until we call
root:Destroy()
on our Root component, or unmount the
VirtualInstance returned by ComponentWithSideEffects some other way.
In a later section, we will go over ways in which virtual instances can be mounted/unmounted automatically based on application state.