Store
The Store object is one of the API changes going from Rocrastinate to Helium, as it is no longer a function that returns a table and is instead a proper Lua object. The Store object is inspired by Redux, a state management library that is often used in JavaScript web applications. Like Redux, the Store is where we can centralize the state of our application. It uses "Actions" to update the application state, and "Reducers" to control how the state changes with a given action.
Helium's Store is NOT equivalent to Redux or Rodux. Some major differences are as follows:
- Helium stores must be passed as the first argument in the constructors of components that use Store objects. This means that the "store" a component is in is not determined by context, but by explicit argument.
- Redux reduces actions by re-creating the entire application state. For the sake of optimization, enabled by the coupling of Helium Components with Store, Helium Store reducers are passed the functions
GetState
andSetState
, which copy/mutate the application's state respectively. - With React/Redux (or Roact/Rodux), changes to the store will immediately re-render a component. In contrast, changes in a Helium store will immediately call
QueueUpdate()
, which defers rendering changes to the next frame binding.
Actions
"Actions" are the only source of information for our Helium Store. They represent information needed to change some portion of the application state, and are represented as lua objects. They are sent using Store:Fire(Action)
or Store:Dispatch(Action)
.
Actions should typically be represented as tables, and have a Type
property denoting what kind of action is being sent. For example:
local MyAction = {
Type = "AddCoins";
Amount = 1;
}
Store:Fire(MyAction)
typedef GenericDictionary table<LuaString, any>;
GenericDictionary MyAction = table(
Type: L"AddCoins",
Amount: 1,
);
Store::Fire(MyAction);
Action Creators
Typically, instead of creating actions directly, we can use "Action Creators", which are simply functions that create actions for us with a given set of arguments. Note that these only create the action, and do not dispatch them:
local function AddCoins(Amount: number)
return {
Type = "AddCoins";
Amount = Amount;
}
end
Store:Fire(AddCoins(1))
typedef GenericDictionary table<LuaString, any>;
GenericDictionary AddCoins(int Amount)
{
return table(
Type: L"AddCoins",
Amount: Amount,
);
}
Store::Fire(AddCoins(1));
Actions can be dispatched at any time from anywhere in the application, including the middle of a Redraw().
Helium actually has a built-in function used to creating this, called MakeActionCreator
. This can be used as seen in the following code:
local AddCoins = Helium.MakeActionCreator("AddCoins", function(Amount: number)
return {
Amount = Amount;
}
end)
Store:Fire(AddCoins(1))
typedef GenericDictionary table<LuaString, any>;
GenericDictionary AddCoinsAction(int Amount)
{
return table(Amount: Amount);
}
void AddCoins = Helium.MakeActionCreator(L"AddCoins", AddCoinsAction);
Store::Fire(AddCoins(1));
Responding to Actions
Like Redux, Helium uses "Reducers", which are functions that respond to an action by modifying a certain portion of the store's state.
Reducers are given three arguments: (Action, GetState, SetState)
.
Action
is the action that was dispatched.GetState(...KeyPath)
is a function that gets a value in the store by nested keys.SetState(...KeyPath, Value)
is a function that sets a value in the store by nested keys.
If we want to set the value of Coins
in the store whenever an AddCoins
action is received, we can use the following code:
local function Reducer(Action, GetState, SetState)
if Action.Type == "AddCoins" then
local Coins = GetState("Coins")
SetState("Coins", Coins + Action.Amount)
end
end
If you're using the MakeActionCreator
function, you can set it up like so:
local AddCoins = require(ReplicatedStorage.AddCoins)
local function Reducer(Action, GetState, SetState)
if Action.Type == AddCoins.ActionName then
local Coins = GetState("Coins")
SetState("Coins", Coins + Action.Amount)
end
end
This code makes a few assumptions:
- There is already a value in the store named
Coins
, and that it is a number. - That the action has a property named
Type
. - That the action (which we've identified as an
AddCoins
action) has a property namedAmount
, and that it is a number.
It is generally best to centralize actions or action creators in an Actions
module, so that these assumptions can be standardized. Additionally, we need to declare the initial state of our store somewhere:
local InitialState = {
Coins = 0;
}
Then, when we call
Store:Fire(AddCoins(1))
our store state should conceptually be mutated to look something like this table:
{
Coins = 1;
}
Additionally, we can nest tables in our store structure:
local InitialState = {
PlayerStats = {Coins = 0};
}
local function Reducer(Action, GetState, SetState)
if Action.Type == AddCoins.ActionName then
local Coins = GetState("PlayerStats", "Coins")
SetState("PlayerStats", "Coins", Coins + Action.Amount)
end
end
GenericDictionary InitialState = table(PlayerStats: table(Coins: 0));
void Reducer(GenericDictionary Action, void GetState, SetState)
{
if Action.Type == AddCoins.ActionName
{
int Coins = GetState(L"PlayerStats", L"Coins");
SetState(L"PlayerStats", L"Coins", Coins + Action.Amount);
}
}
In the above example, we provide an aditional argument to GetState
and SetState
. These are just strings representing the path of nested keys leading to the exact value we want to get/set in our store.
If we kept this all in the same module, we may run into a problem when our tree becomes more complex:
local function Reducer(Action, GetState, SetState)
if Action.Type == "DoSomethingInASpecificDomain" then
SetState("Path", "To", "Specific", "Domain", Value)
elseif ... then
...
end
end
void Reducer(GenericDictionary Action, void GetState, SetState)
{
if Action.Type == L"DoSomethingInASpecificDomain"
{
SetState(L"Path", L"To", L"Specific", L"Domain", Action.Value);
} elseif ...
{
...
}
}
This can become very verbose. It would be much simpler if we could create a reducer that just deals with playerStats, and another reducer that just deals with some other domain.
To do this, you can use the CombineReducers()
function. Let's say we put our main reducer in a module called "RootReducer", and nested reducers for playerStats underneath the root reducer:
RootReducer ModuleScript
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Helium = require(ReplicatedStorage.Helium)
local PlayerStats = require(script.PlayerStats)
local Reducer = Helium.CombineReducers({
PlayerStats = PlayerStats.Reducer;
})
local InitialState = {
PlayerStats = PlayerStats.InitialState;
}
return {
Reducer = Reducer;
InitialState = InitialState;
}
RootReducer.PlayerStats ModuleScript
local function Reducer(Action, GetState, SetState)
if Action.Type == "AddCoins" then
local Coins = GetState("Coins")
SetState("Coins", Coins + Action.Amount)
end
end
local InitialState = {Coins = 0;}
return {
Reducer = Reducer;
InitialState = InitialState;
}
If we wanted to, we could subdivide this even further by making a reducer for coins, and use CombineReducers()
in the PlayerStats module instead. The "coins" module would then look something like this:
RootReducer.PlayerStats.Coins ModuleScript
local function Reducer(Action, GetState, SetState)
if Action.Type == "AddCoins" then
SetState(GetState() + Action.Amount)
end
end
local InitialState = 0
return {
Reducer = Reducer;
InitialState = InitialState;
}
Now that we've separated the concerns of our reducers and actions, how do we actually create the store and have it interact with our application?
Helium uses the function Store.new(Reducer, InitialState)
for this.
Putting it all together, we can create a very simple store that reduces a single value of "Coins"
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Helium = require(ReplicatedStorage.Helium)
-- Typically this would be put in a separate module called "Actions"
local AddCoins = Helium.MakeActionCreator("AddCoins", function(Amount: number)
return {
Amount = Amount;
}
end)
-- Typically this would be put in a separate module called "Reducer" or "RootReducer"
local function Reducer(Action, GetState, SetState)
if Action.Type == AddCoins.ActionName then
SetState(GetState() + Action.Amount)
end
end
local InitialState = 0
local CoinsStore = Helium.Store.new(Reducer, InitialState) -- You can also do Helium.CreateStore(Reducer, InitialState)
print(CoinsStore:GetState()) -- 0
CoinsStore:Fire(AddCoins(10))
print(CoinsStore:GetState()) -- 10
CoinsStore:Dispatch(AddCoins(10)) -- Dispatch is also valid
print(CoinsStore:GetState()) -- 20
typedef GenericDictionary table<LuaString, any>;
RbxInstance ReplicatedStorage = game::GetService @ ReplicatedStorage;
GenericDictionary Helium = require(ReplicatedStorage.Helium);
const int INITIAL_STATE = 0;
// Typically this would be put in a separate module called "Actions"
GenericDictionary AddCoinsFunction(int Amount)
{
return table(Amount: Amount);
}
void AddCoins = Helium.MakeActionCreator(L"AddCoins", AddCoinsFunction);
// Typically this would be put in a separate module called "Reducer" or "RootReducer"
void Reducer(GenericDictionary Action, void GetState, SetState)
{
if Action.Type == AddCoins.ActionName
{
SetState(GetState() + Action.Amount);
}
}
entry void Main(void)
{
GenericDictionary CoinsStore = Helium.Store.new(Reducer, INITIAL_STATE); // You can also do Helium.CreateStore(Reducer, InitialState)
print(CoinsStore::GetState()); // 0
CoinsStore::Fire(AddCoins(10));
print(CoinsStore::GetState()); // 10
CoinsStore::Dispatch(AddCoins(10)); // Dispatch is also valid
print(CoinsStore::GetState()); // 20
}
#
FunctionsIs
#
Store.
Is
(
Object:Â
any
--
The object to check against.
) →Â
boolean
--
Whether or not the object is a Store.
Determines if the passed object is a Store.
print(Helium.Store.Is(Helium.Store.new(function() end, {}))) -- true
print(Helium.Store.Is({})) -- false
print(Helium.Store.Is(true)) -- false
new
#
Store.
new
(
Reducer:Â
ReducerFunction
,
--
The reducer function.
InitialState:Â
NonNil
--
The initial state.
) →Â
Store
Creates a new Store object.
local Store = Helium.Store.new(function()
end, {ThisIsAStore = true})
ApplyMiddleware
#
Store:
ApplyMiddleware
(
Middleware:Â
(
Store:Â
Store
)
 →Â
(
NextDispatch:Â
(
Action:Â
BaseAction
)
 →Â
(
)
)
 →Â
(
Action:Â
BaseAction
)
 →Â
(
)
--
The middleware function you are applying.
) →Â
Store
--
The self reference for chaining these calls.
Applies a Middleware to the Store. Middlware are simply functions that intercept actions upon being dispatched, and allow custom logic to be applied to them. The way middlewares intercept actions is by providing a bridge in between store.dispatch being called and the root reducer receiving those actions that were dispatched.
local SetValueA = Helium.MakeActionCreator("SetValueA", function(Value)
return {Value = Value}
end)
local Store = Helium.Store.new(function(Action, _, SetState)
if Action.Type == SetValueA.ActionName then
SetState("ValueA", Action.Value)
end
end, {
ValueA = "A";
ValueB = {ValueC = "C"};
})
Store:ApplyMiddleware(Helium.LoggerMiddleware):ApplyMiddleware(Helium.InspectorMiddleware)
Store:Fire(SetValueA("ValueA"))
--[[
Prints:
{
["Value"] = "ValueA",
["Type"] = "SetValueA"
}
SetValueA
]]
Connect
#
Store:
Connect
(
StringKeyPath:Â
string
,
--
The string path to run the function at. An empty string is equal to any changes made.
Function:Â
(
)
 →Â
(
)
--
The function you want to run when the state is updated.
) →Â
(
)
 →Â
(
)
--
A function that disconnects the connection.
Connects a function to the given string keypath.
local function SetValue(Value)
return {Value = Value}
end
local SetValueA = Helium.MakeActionCreator("SetValueA", SetValue)
local SetValueC = Helium.MakeActionCreator("SetValueC", SetValue)
local SetValueD = Helium.MakeActionCreator("SetValueD", SetValue)
local Store = Helium.Store.new(function(Action, _, SetState)
if Action.Type == SetValueA.ActionName then
SetState("ValueA", Action.Value)
elseif Action.Type == SetValueC.ActionName then
SetState("ValueB", "ValueC", Action.Value)
elseif Action.Type == SetValueD.ActionName then
SetState("ValueD", Action.Value)
end
end, {
ValueA = "A";
ValueB = {ValueC = "C"};
})
local Disconnect = Store:Connect("", function()
print("The store was changed!", Store:GetState())
end)
Store:Connect("ValueA", function()
print("ValueA was changed!", Store:GetState("ValueA"))
end)
Store:Connect("ValueB.ValueC", function()
print("ValueB.ValueC was changed!", Store:GetState("ValueB", "ValueC"))
end)
Store:Fire(SetValueD("ValueD"))
--[[
Prints:
The store was changed! {
["ValueA"] = "A",
["ValueB"] = {
["ValueC"] = "C"
},
["ValueD"] = "ValueD"
}
]]
Disconnect()
Store:Fire(SetValueA("ValueA")) -- Prints: ValueA was changed! ValueA
Store:Fire(SetValueC("ValueC")) -- Prints: ValueB.ValueC was changed! ValueC
Dispatch
#
Store:
Dispatch
(
Action:Â
BaseAction
--
The Action you are dispatching.
) →Â
(
)
Dispatches an Action to the Store.
local DispatchAction = Helium.MakeActionCreator("DispatchAction", function(Value)
return {
Value = Value;
}
end)
Store:Dispatch(DispatchAction("Value"))
Store:Dispatch({
Type = "AwesomeAction";
AwesomeValue = true;
})
Fire
#
Store:
Fire
(
Action:Â
BaseAction
--
The Action you are dispatching.
) →Â
(
)
Dispatches an Action to the Store.
local DispatchAction = Helium.MakeActionCreator("DispatchAction", function(Value)
return {
Value = Value;
}
end)
Store:Fire(DispatchAction("Value"))
Store:Fire({
Type = "AwesomeAction";
AwesomeValue = true;
})
GetState
#
Store:
GetState
(
...:Â
string?
--
The string path you want to get.
) →Â
T
--
The current Store state.
Gets the current Store state. If the value returned is a table, it is deep copied to prevent You can optionally provide a path.
local Store = Helium.Store.new(function() end, {
ValueA = "A";
ValueB = {ValueC = "C"};
})
print(Store:GetState()) -- The state.
print(Store:GetState("ValueA")) -- "A"
print(Store:GetState("ValueB", "ValueC")) -- "C"
InspectState
#
Store:
InspectState
(
) →Â
string
--
The string representation of the Store's current state.
Returns a string representation of the Store's current state. This is useful for debugging things.
local Store = Helium.Store.new(function() end, {
ValueA = "A";
ValueB = {ValueC = "C"};
})
print(Store:InspectState())
--[[
Prints:
{
["ValueA"] = "A",
["ValueB"] = {
["ValueC"] = "C"
}
}
]]
SetState
#
Store:
SetState
(
...:Â
string?
,
--
The path of the state to set.
Value:Â
any
--
The value you are setting. This is always required, think of it as setting a Url -> example.com/path/to/value == "path", "to", "value"
) →Â
(
)
Sets the current Store state. The varargs are the string paths you want to set the state of.
info
The path is totally optional and skipping it will just result in you editing the root of the state table.
warning
SetState
overwrites the table, so if you want to preserve the original table, you should be using actions and the reducer function.
local Store = Helium.Store.new(function() end, {
ValueA = "A";
})
print(Store:GetState("ValueA")) -- "A"
Store:SetState("ValueA", "ValueA")
print(Store:GetState("ValueA")) -- "ValueA"
local Store = Helium.Store.new(function() end, {
ValueB = {ValueC = "C"};
})
print(Store:GetState("ValueB", "ValueC")) -- "C"
Store:SetState("ValueB", "ValueC", 3)
print(Store:GetState("ValueB", "ValueC")) -- "3"
Subscribe
#
Store:
Subscribe
(
StringKeyPath:Â
string
,
--
The string path to run the function at. An empty string is equal to any changes made.
Function:Â
(
)
 →Â
(
)
--
The function you want to run when the state is updated.
) →Â
(
)
 →Â
(
)
--
A function that disconnects the connection.
Connects a function to the given string keypath.
local function SetValue(Value)
return {Value = Value}
end
local SetValueA = Helium.MakeActionCreator("SetValueA", SetValue)
local SetValueC = Helium.MakeActionCreator("SetValueC", SetValue)
local SetValueD = Helium.MakeActionCreator("SetValueD", SetValue)
local Store = Helium.Store.new(function(Action, _, SetState)
if Action.Type == SetValueA.ActionName then
SetState("ValueA", Action.Value)
elseif Action.Type == SetValueC.ActionName then
SetState("ValueB", "ValueC", Action.Value)
elseif Action.Type == SetValueD.ActionName then
SetState("ValueD", Action.Value)
end
end, {
ValueA = "A";
ValueB = {ValueC = "C"};
})
local Disconnect = Store:Subscribe("", function()
print("The store was changed!", Store:GetState())
end)
Store:Subscribe("ValueA", function()
print("ValueA was changed!", Store:GetState("ValueA"))
end)
Store:Subscribe("ValueB.ValueC", function()
print("ValueB.ValueC was changed!", Store:GetState("ValueB", "ValueC"))
end)
Store:Dispatch(SetValueD("ValueD"))
--[[
Prints:
The store was changed! {
["ValueA"] = "A",
["ValueB"] = {
["ValueC"] = "C"
},
["ValueD"] = "ValueD"
}
]]
Disconnect()
Store:Dispatch(SetValueA("ValueA")) -- Prints: ValueA was changed! ValueA
Store:Dispatch(SetValueC("ValueC")) -- Prints: ValueB.ValueC was changed! ValueC