State
Components can hold their own data privately using state objects. This can be useful, but you should be careful when adding state.
Creation¶
You can create state objects inside components as you would anywhere else.
local HOVER_COLOUR = Color3.new(0.5, 0.75, 1)
local REST_COLOUR = Color3.new(0.25, 0.5, 1)
local function Button(
scope: Fusion.Scope,
props: {
-- ... some properties ...
}
)
local isHovering = scope:Value(false)
return scope:New "TextButton" {
BackgroundColor3 = scope:Computed(function(use)
return if use(isHovering) then HOVER_COLOUR else REST_COLOUR
end),
-- ... ... some more code ...
}
end
Because these state objects are made with the same scope
as the rest of the
component, they're destroyed alongside the rest of the component.
Top-Down Control¶
Remember that Fusion mainly works with a top-down flow of control. It's a good idea to keep that in mind when adding state to components.
When you're making reusable components, it's more flexible if your component can be controlled externally. Components that control themselves entirely are hard to use and customise.
Consider the example of a check box. Each check box often reflects a state object under the hood:
It might seem logical to store the state object inside the check box, but this causes a few problems:
- because the state is hidden, it's awkward to read and write from outside
- often, the user already has a state object representing the same setting, so now there's two state objects where one would have sufficed
local function CheckBox(
scope: Fusion.Scope,
props: {
-- ... some properties ...
}
)
local isChecked = scope:Value(false) -- problematic
return scope:New "ImageButton" {
[OnEvent "Activated"] = function()
isChecked:set(not peek(isChecked))
end,
-- ... some more code ...
}
end
A slightly better solution is to pass the state object in. This ensures the controlling code has easy access to the state if it needs it. However, this is not a complete solution:
- the user is forced to store the state in a
Value
object, but they might be computing the value dynamically with other state objects instead - the behaviour of clicking the check box is hardcoded; the user cannot intercept the click or toggle a different state
local function CheckBox(
scope: Fusion.Scope,
props: {
IsChecked: Fusion.Value<boolean> -- slightly better
}
)
return scope:New "ImageButton" {
[OnEvent "Activated"] = function()
props.IsChecked:set(not peek(props.IsChecked))
end,
-- ... some more code ...
}
end
That's why the best solution is to use UsedAs
to create read-only
properties, and add callbacks for signalling actions and events.
- because
UsedAs
is read-only, it lets the user plug in any data source, including dynamic computations - because the callback is provided by the user, the behaviour of clicking the check box is completely customisable
local function CheckBox(
scope: Fusion.Scope,
props: {
IsChecked: UsedAs<boolean>, -- best
OnClick: () -> ()
}
)
return scope:New "ImageButton" {
[OnEvent "Activated"] = function()
props.OnClick()
end,
-- ... some more code ...
}
end
The control is always top-down here; the check box's appearance is fully controlled by the creator. The creator of the check box decides to switch the setting when the check box is clicked.
In Practice¶
Setting up your components in this way makes extending their behaviour incredibly straightforward.
Consider a scenario where you wish to group multiple options under a 'main' check box, so you can turn them all on/off at once.
The appearance of that check box would not be controlled by a single state, but
instead reflects the combination of multiple states. Because the code uses
UsedAs
, you can represent this with a Computed
object.
local playMusic = scope:Value(true)
local playSFX = scope:Value(false)
local playNarration = scope:Value(true)
local checkBox = scope:CheckBox {
Text = "Play sounds",
IsChecked = scope:Computed(function(use)
local anyChecked = use(playMusic) or use(playSFX) or use(playNarration)
local allChecked = use(playMusic) and use(playSFX) and use(playNarration)
if not anyChecked then
return "unchecked"
elseif not allChecked then
return "partially-checked"
else
return "checked"
end
end)
}
You can then implement the 'check all'/'uncheck all' behaviour inside OnClick
:
local playMusic = scope:Value(true)
local playSFX = scope:Value(false)
local playNarration = scope:Value(true)
local checkBox = scope:CheckBox {
-- ... same properties as before ...
OnClick = function()
local allChecked = peek(playMusic) and peek(playSFX) and peek(playNarration)
playMusic:set(not allChecked)
playSFX:set(not allChecked)
playNarration:set(not allChecked)
end
}
Because the check box was written to be flexible, it can handle complex usage easily.
Best Practices¶
Those examples lead us to the golden rule of reusable components:
Golden Rule
Reusable components should reflect program state. They should not control program state.
At the bottom of the chain of control, components shouldn't be massively responsible. At these levels, reflective components are easier to work with.
As you go up the chain of control, components get broader in scope and less reusable; those places are often suitable for controlling components.
A well-balanced codebase places controlling components at key, strategic locations. They allow higher-up components to operate without special knowledge about what goes on below.
At first, this might be difficult to do well, but with experience you'll have a better intuition for it. Remember that you can always rewrite your code if it becomes a problem!