React-like state management and hooks for Lua

This module provides a set of hooks and utilities that allow for efficient state management in Lua applications, mimicking the popular React hooks API.
It’s designed to bring the power and flexibility of React-style state management
to Lua environments, making it easier to build and maintain complex, stateful applications.

Features

constructor (.new)

Creates a new State instance
@return State A new State instance

local State = require("State")
local state = State.new()

-- Now you can use state to manage your application's state
local getCount, setCount = state:useState(0)
print(getCount()) -- Output: 0
    
state:useEffect(function()
    print("Count changed to:", getCount())
end, {getCount})
    
setCount(1) -- This will trigger the effect
:useState

Creates a state variable and returns its value and a setter function
@param initialValue The initial value of the state variable
@return function A getter function to retrieve the current value of the state variable
@return function A setter function to update the state variable

local state = State.new()
-- Create a state variable for user's name
local getName, setName = state:useState("John Doe")

-- Use the getter
print("Current name:", getName()) -- Output: Current name: John Doe

-- Use the setter
setName("Jane Doe")
print("Updated name:", getName()) -- Output: Updated name: Jane Doe

-- Use a function to update based on previous state
setName(function(prevName)
    return prevName .. " Jr."
end)

print("Final name:", getName()) -- Output: Final name: Jane Doe Jr.
:useEffect

Runs an effect function when dependencies change. The effect function is executed immediately upon creation and then re-executed whenever any of its dependencies change.

@param effect The effect function to run. This function can optionally return a cleanup function.
@param dependencies An optional table of dependencies that trigger the effect when changed. If omitted, the effect runs on every update.

The effect function is called with no arguments. If it returns a function, that function will be used as a cleanup function and will be called before the effect runs again or when the component is unmounted.

If dependencies are provided, the effect will only re-run if any of the dependencies have changed since the last render. Comparing dependencies is done using reference equality.

local State = require("State")
local myState = State.new()

-- Effect without dependencies (runs on every update)
myState:useEffect(function()
    print("This effect runs on every update")
end)

-- Effect with empty dependencies (runs only once, on mount)
myState:useEffect(function()
    print("This effect runs only once, on mount")
    return function()
        print("This cleanup runs when the component unmounts")
    end
end, {})

-- Effect with dependencies
local getCount, setCount = myState:useState(0)
myState:useEffect(function()
    print("Count changed to: " .. getCount())
    -- Optional cleanup function
    return function()
        print("Cleaning up previous effect for count: " .. getCount())
    end
end, {getCount})

-- Effect with cleanup (simulating a timer)
local getIsActive, setIsActive = myState:useState(true)
myState:useEffect(function()
    if getIsActive() then
        local timerId = setTimer(function()
            print("Timer fired!")
            setIsActive(false)
        end, 5000)
        -- Cleanup function to clear the timer
        return function()
            clearTimer(timerId)
            print("Timer cleared")
        end
    end
end, {getIsActive})

-- Simulating state changes
setCount(1)  -- This will trigger the effect with count dependency
setIsActive(false)  -- This will trigger the effect with isActive dependency and run the cleanup
:useContext

Returns the value of a context
@param context The context object
@return The value of the context

local State = require("State")
local myState = State.new()

-- Create a context
local ThemeContext = {value = "light"}

-- Use the context
local function Component()
    local theme = myState:useContext(ThemeContext)
    print("Current theme:", theme) -- Output: Current theme: light
    -- You can use the theme value to conditionally render or style your component
    if theme == "light" then
        print("Rendering light theme")
    else
        print("Rendering dark theme")
    end
end

-- Simulate changing the context
ThemeContext.value = "dark"
Component() -- This will now use the updated context value

-- You can also use context with more complex values
local UserContext = {value = {name = "John", role = "Admin"}}
local function UserInfo()
    local user = myState:useContext(UserContext)
    print("User:", user.name, "Role:", user.role)
end
UserInfo() -- Output: User: John Role: Admin
:useReducer

Creates a reducer state and returns its value and a dispatch function
@param reducer The reducer function
@param initialState The initial state
@return function A getter function to retrieve the current state
@return function A dispatch function to update the state

local State = require("State")
local myState = State.new()

-- Define a reducer function
local function counterReducer(state, action)
    if action == "INCREMENT" then
        return state + 1
    elseif action == "DECREMENT" then
        return state - 1
    elseif action == "RESET" then
        return 0
    else
        return state
    end
end

-- Use the reducer
local getCount, dispatch = myState:useReducer(counterReducer, 0)

-- Use the state
print("Initial count:", getCount()) -- Output: Initial count: 0

-- Dispatch actions
dispatch("INCREMENT")
print("After increment:", getCount()) -- Output: After increment: 1

dispatch("INCREMENT")
print("After another increment:", getCount()) -- Output: After another increment: 2

dispatch("DECREMENT")
print("After decrement:", getCount()) -- Output: After decrement: 1

dispatch("RESET")
print("After reset:", getCount()) -- Output: After reset: 0

-- You can also use more complex state and actions
local function todoReducer(state, action)
    if action.type == "ADD_TODO" then
        return table.insert(state, {text = action.payload, completed = false})
    elseif action.type == "TOGGLE_TODO" then
        state[action.payload].completed = not state[action.payload].completed
        return state
    else
        return state
    end
end

local getTodos, dispatchTodo = myState:useReducer(todoReducer, {})

dispatchTodo({type = "ADD_TODO", payload = "Learn Lua"})
dispatchTodo({type = "ADD_TODO", payload = "Master State Management"})
dispatchTodo({type = "TOGGLE_TODO", payload = 1})

for i, todo in ipairs(getTodos()) do
    print(i, todo.text, todo.completed)
end
-- Output:
-- 1 Learn Lua true
-- 2 Master State Management false
:useCallback

Returns a memoized callback function
@param callback The callback function to memoize
@param dependencies A table of dependencies
@return function The memoized callback function

local State = require("State")
local myState = State.new()

-- Create some state
local getName, setName = myState:useState("John")
local getGreeting, setGreeting = myState:useState("Hello")

-- Create a memoized callback
local greet = myState:useCallback(function()
    print(getGreeting() .. ", " .. getName() .. "!")
end, {getName, getGreeting})

-- Use the memoized callback
greet() -- Output: Hello, John!

-- Change the name
setName("Jane")

greet() -- Output: Hello, Jane!

-- Change the greeting
setGreeting("Hi")
greet() -- Output: Hi, Jane!

-- Example with parameters
local multiply = myState:useCallback(function(a, b)
    return a * b
end, {})
print(multiply(5, 3)) -- Output: 15

-- The callback will only be recreated if the dependencies change
local getCount, setCount = myState:useState(0)
local logCount = myState:useCallback(function()
    print("Current count:", getCount())
end, {getCount})

logCount() -- Output: Current count: 0
setCount(5)
logCount() -- Output: Current count: 5
:useMemo

Returns a memoized value
@param create A function that creates the value to be memoized
@param dependencies A table of dependencies
@return The memoized value
@usage

local State = require("State")
local myState = State.new()

-- Create some state
local getWidth, setWidth = myState:useState(1920)
local getHeight, setHeight = myState:useState(1080)

-- Use useMemo to compute an expensive calculation
local getArea = myState:useMemo(function()
    print("Computing area...") -- This will only print when width or height changes
    return getWidth() * getHeight()
end, {getWidth, getHeight})
print("Area:", getArea()) -- Output: Computing area... \n Area: 2073600
print("Area:", getArea()) -- Output: Area: 2073600 (no recomputation)
setWidth(3840)
print("Area:", getArea()) -- Output: Computing area... \n Area: 4147200

-- Example with more complex computation
local getNums, setNums = myState:useState({1, 2, 3, 4, 5})
local getSum = myState:useMemo(function()
    print("Computing sum...")
    local sum = 0
    for _, num in ipairs(getNums()) do
        sum = sum + num
    end
    return sum
end, {getNums})
print("Sum:", getSum()) -- Output: Computing sum... \n Sum: 15
print("Sum:", getSum()) -- Output: Sum: 15 (no recomputation)
setNums({1, 2, 3, 4, 5, 6})
print("Sum:", getSum()) -- Output: Computing sum... \n Sum: 21
:useRef

Creates a mutable ref object that persists for the full lifetime of the component.

@param initialValue The initial value of the ref. Can be of any type.
@return table A ref object with a ‘current’ property that can be read from or assigned to.

@description
The useRef function is useful for keeping any mutable value around similar to how you’d use instance fields in classes.
This could be used to store a reference to a DOM element, to keep track of interval IDs, or any other mutable data
that you want to persist without causing a re-render when it’s changed.
The returned object will persist for the full lifetime of the component. It’s important to note that changing the
‘current’ property doesn’t cause a re-render.

local State = require("State")
local state = State.new()
-- Create a ref to store a DOM element
local inputRef = state:useRef(nil)
-- In your render function, you might use it like this:
-- <input ref={inputRef} />
-- Later in your code, you can access or modify the ref
local function handleClick()
    inputRef.current:focus()  -- Assuming 'current' is set to a DOM element that has a focus method
end
-- You can also use it to store any mutable value
local intervalRef = state:useRef(nil)
state:useEffect(function()
    intervalRef.current = setInterval(function()
        print("Interval triggered")
    end, 1000)
    return function()
        clearInterval(intervalRef.current)
    end
end, {})

State.rbxm (7.6 KB)

3 Likes

Personally I will be using this. Thanks for sharing