Skip to content

Instantly share code, notes, and snippets.

@Mullets-Gavin
Created March 16, 2021 12:19
Show Gist options
  • Select an option

  • Save Mullets-Gavin/c8fd2ec03d2bda6c6107a57c497720c0 to your computer and use it in GitHub Desktop.

Select an option

Save Mullets-Gavin/c8fd2ec03d2bda6c6107a57c497720c0 to your computer and use it in GitHub Desktop.
s - A state management library for Roblox

s

version docs support

A state management library for Roblox

Documentation

type s.none

Used as a replacement for nil

Example:

s:set({ undefined = s.none })
print(s.none) --> "none"
type s.all

A subscription key to watch all changes

Example:

s:subscribe(s.all, function)
print(s.all) --> "all"
map s.state

A map of the state store to read states

Example:

s:set({ counter = 0 })
print(s.state.counter) --> 0
function s.new(key)

Set state of keys and values and apply attributes if the store key is an instance and the value is a valid attribute type

Parameters:

  • key: any -- the unique key for the store

Returns:

  • store -- a state store

Example:

local playerStore = s.new(game.Players.LocalPlayer)
local gameStore = s.new(game)
method s:set(state, value?)

Set state of keys and values and apply attributes if the store key is an instance and the value is a valid attribute type

Parameters:

  • state: table | string -- the state to set
  • value: any? -- an optional state to set as

Returns:

  • table -- the updated state table

Example:

s:set({ counter = 0 })
s:set("counter", s.state.counter + 1)
s:set({
	counter = s.state.counter + 1
})
method s:fire(state, value)

Fire all callbacks on the key provided with the updated value

Parameters:

  • state: string -- the state to fire
  • value: any -- the value to update with

Returns:

  • self -- the store itself

Example:

s:fire("counter", 10)
method s:roact(component, keys?)

Initialize a roact component with the state store and injects the states from the store into the component

Parameters:

  • component: table -- the roact component class
  • keys: table? -- the optional keys (or all!) to inject state, leave nil for all

Returns:

  • component -- return the roact component

Example:

return s:roact(Component, { "counter" }) -- track and inject counter into the component
return s:roact(Component) -- track and inject all state changes into the component
method s:define(interface)

Define an interface with t to filter state and maintain global changes to the store

Parameters:

  • interface: function -- the t.interface or t.strictInterface function

Returns:

  • interface -- returns the same t interface function

Example:

local interface = s:define(t.strictInterface({
	counter = t.number,
	flag = t.boolean,
}))

s:set({
	counter = 0, --
	flag = Color3.fromRGB(0, 0, 0), --
}) -- this will error since flag goes against the interface
method s:sanitize(value)

Sanitizes a data value to check if it's valid for an attribute and returns a boolean whether or not it is

Parameters:

  • value: table | any -- the value or table of values to be sanitized

Returns:

  • boolean -- true if passed, false if not

Example:

print("is number valid:", s:sanitize(0)) --> "is number valid: true"
print("is color3 valid:", s:sanitize(Color3.fromRGB(0, 0, 0,))) --> "is color3 valid: true"
print("is enum valid:", s:sanitize(Enum.Keycode.Q)) --> "is enum valid: false"
method s:subscribe(keys, callback)

Watch for changes on all keys or specified keys with a callback function. Use s.all to tell the subscription to watch for all changes that occur.

Parameters:

  • keys: table | any -- the keys to watch, use s.all as your key to watch all changes
  • callback: function -- the function to call when a change occurs, provides a context object

Arguments:

  • context = { state: any, value: any } -- the context object passed in the callback function with .state and .value

Returns:

  • Subscription -- returns a subscription object to disconnect the subscription

Example:

local subscription = s:subscribe(s.all, function(context)
	print(context.state .. ",", context.value) --> "hello, world!"
end)

s:set({ hello = "world!" })
subscription:unsubscribe()

s:subscribe({ "counter", "stage" }, function(context)
	if context.state == "counter" then
		print("countdown:", context.value)
	elseif context.state == "stage" then
		print("moving to new stage:", context.value)
	end
end)
method Subscription:unsubscribe()

Unsubscribes a subscription and disconnects the object

Returns:

  • nil

Example:

local subscription = s:subscribe(s.all, function(context)
	subscription:unsubscribe()
end)

s:set({ counter = 0 })

License

This project is licensed under the MIT license. See LICENSE for details.

--[=[
s, a state management library for Roblox with easy integration of t
Goals:
- integrates with t
- handles replication
- minimal api, all surface level
- allows for state "stores"
- global stores for roact
]=]
local s = {}
s.stores = {}
s.attributes = true
s._attributeTypes = {
"string",
"boolean",
"number",
"UDim",
"UDim2",
"BrickColor",
"Color3",
"Vector2",
"Vector3",
"NumberSequence",
"ColorSequence",
"NumberRange",
"Rect",
}
do
s.none = newproxy(true)
getmetatable(s.none).__tostring = function()
return "none"
end
s.all = newproxy(true)
getmetatable(s.all).__tostring = function()
return "all"
end
end
--[=[
Copy a table
@param master table -- table to copy
@return clone table -- the cloned table
]=]
local function copy(master: table): table
local clone = {}
for key, value in pairs(master) do
if typeof(value) == "table" then
clone[key] = copy(value)
else
clone[key] = value
end
end
return clone
end
--[=[
Wrap and call a function instantly
@param code () -> () -- the function to call
]=]
local function wrap(code: (any) -> (), ...): nil
local thread = coroutine.create(code)
local ran, response = coroutine.resume(thread, ...)
if not ran then
local trace = debug.traceback(thread)
error(response .. "\n" .. trace, 2)
end
end
--[=[
A new store or returns a current one
@param key any -- the unique key for the store
@return s typeof(s.new()) -- the store class
]=]
function s.new(key: any?): typeof(s.new())
key = key ~= nil and key or game
if s.stores[key] then
return s.stores[key]
end
s.__index = s
s.stores[key] = setmetatable({
state = {},
_uid = 0,
_key = key,
_type = typeof(key),
_events = {},
_subscriptions = {},
}, s)
if s.stores[key]._type == "Instance" then
local new = {}
for scope, value in pairs(key:GetAttributes()) do
new[scope] = value
end
s.stores[key].state = new
end
return s.stores[key]
end
--[=[
Set the state to the store and fire any subscriptions
@param state table | string -- the state to set
@param value any? -- an optional state to set as
@return update table -- the updated state table
]=]
function s:set(state: table | string, value: any?): table
assert(state ~= nil, "'set' Argument 1 missing or nil")
local update = copy(self.state)
if typeof(state) == "table" then
for key, data in pairs(state) do
local new = data ~= s.none and data
update[key] = new
self:fire(key, new)
end
elseif typeof(state) == "string" and value ~= nil then
local new = value ~= s.none and value
update[state] = new
self:fire(state, new)
end
self.state = update
if self._interface then
local success, msg = self._interface(update)
assert(success, msg)
end
if self.attributes and self._type == "Instance" then
for key, data in pairs(update) do
if not self:sanitize(data) then
continue
end
if self._key:GetAttribute(key) ~= data then
self._key:SetAttribute(state, data)
end
end
end
return update
end
--[=[
Sanitizes a data value and returns a boolean
@param value table | any -- the value or table of values to be sanitized
@return check boolean -- true if passed, false if not
]=]
function s:sanitize(value: table | any): boolean
if typeof(value) == "table" then
for key, data in pairs(value) do
if not table.find(s._attributeTypes, typeof(data)) then
return false, key
end
end
return true
else
return table.find(s._attributeTypes, typeof(value)) ~= nil
end
end
--[[
Fire all callbacks on the key provided
@param state string -- the state to fire
@param value any -- the value to update with
@return s typeof(s.new()) -- self class
]]
function s:fire(state: string, value: any): typeof(s.new())
for _, subscriptions in ipairs(self._subscriptions) do
if not table.find(subscriptions.keys, s.all) and not table.find(subscriptions.keys, state) then
continue
end
wrap(subscriptions.callback, {
state = state,
value = value,
})
end
return self
end
--[=[
Watch for changes on all keys or specified keys
@param keys table | any -- the keys to watch
@param callback () -> () -- the function to call
@return methods table -- the unsubscribe method to disconnect
]=]
function s:subscribe(keys: table | any, callback: () -> ()): table
assert(keys ~= nil, "'subscribe' Argument 1 missing or nil")
assert(typeof(callback) == "function", "'subscribe' Argument 2 must be a function")
local subscription = {
keys = typeof(keys) == "table" and keys or { keys },
callback = callback,
}
table.insert(self._subscriptions, subscription)
local events = {}
if self.attributes and self._type == "Instance" then
for index, key in ipairs(keys) do
events[index] = self._key:GetAttributeChangedSignal(key):Connect(function()
local attributeValue = self._key:GetAttribute(key)
local stateValue = self.state[key]
if attributeValue ~= stateValue then
self:set({ [key] = attributeValue })
end
end)
end
end
return {
unsubscribe = function(): nil
if not subscription then
return
end
local find = table.find(self._subscriptions, subscription)
if find then
table.remove(self._subscriptions, find)
end
for _, event in pairs(events) do
event:Disconnect()
end
events = nil
subscription = nil
end,
}
end
--[=[
Initialize a roact component with the state store
@param component table -- the roact component class
@param keys table? -- the optional keys (or all!) to inject state
@return component table -- return the roact component
]=]
function s:roact(component: table, keys: table?): table
assert(typeof(component) == "table", "'roact' Argument 1 must be a Roact Component")
assert(typeof(keys) == "table", "'roact' Argument 2 expected a table, got '" .. typeof(keys) .. "'")
local initialMethods = {
init = component.init,
willUnmount = component.willUnmount,
}
component.init = function(this, ...)
local initialState = {}
if initialMethods.init then
initialMethods.init(this, ...)
end
if typeof(keys) == "table" then
for _, key in ipairs(keys) do
initialState[key] = self.state[key]
end
self._componentSubscription = self:subscribe(keys, function(context)
this:setState({ [context.state] = context.value })
end)
else
initialState = self.state
self._componentSubscription = self:subscribe(s.all, function(context)
this:setState({ [context.state] = context.value })
end)
end
this:setState(initialState)
end
component.willUnmount = function(this, ...)
if initialMethods.willUnmount then
initialMethods.willUnmount(this, ...)
end
if self._componentSubscription then
self._componentSubscription:unsubscribe()
self._componentSubscription = nil
end
end
return component
end
--[=[
Define an interface with t to filter state
@param interface () -> () -- the t.interface or t.strictInterface function
@return interface () -> () -- returns the same t interface function
]=]
function s:define(interface: () -> ()): () -> ()
assert(typeof(interface) == "function", "'s:interface' only takes an interface from t")
self._interface = interface
return interface
end
--[=[
A replacement for s.new()
@param key any -- the key can be anything except nil
@return s typeof(s.new()) -- the state store
]=]
function s:__call(key: any): typeof(s.new())
if s.stores[key] then
s.stores[key]._key = key
s.stores[key]._type = typeof(key)
return s.stores[key]
end
return s.new(key)
end
--[=[
When debugging, use print(s) to check the store key
@return key string -- the key name or path
]=]
function s:__tostring()
return "s: " .. typeof(self._key) == "Instance" and self._key:GetFullName() or tostring(self._key)
end
return s.new(game)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment