Skip to content

State Management (Hooks)

Vulpis implements a reactive state system similar to React's useState, but with a key difference: State keys are global strings.

When you update state, the engine automatically detects the change, re-runs your App() function to generate a new Virtual DOM, and efficiently updates only the parts of the UI that changed (Reconciliation).

API Reference

useState(key, defaultValue)

Retrieves the current value for a given state key. If the key does not exist yet, it initializes it with the defaultValue.

Parameters:

ParameterTypeRequiredDescription
keystringYesA unique identifier for this state variable.
defaultValueanyNoThe initial value (number, string, or boolean) if the state hasn't been set yet.

Returns:

  • The current value of the state (or the default value).

Example:

lua
local count = useState("app_counter", 0)
local username = useState("user_name", "Guest")

setState(key, newValue)

Updates the value of a state key and triggers a re-render of the UI.

Parameters:

ParameterTypeRequiredDescription
keystringYesThe unique identifier of the state to update.
newValueanyYesThe new value to store. Supports numbers, strings, and booleans.

Behavior: Calling setState marks the internal State Manager as "dirty". On the next frame tick, the engine will re-execute your Lua App function and reconcile the changes to the screen.

vulpis.markDirty()

Safely forces the internal engine to register a state change and trigger a UI re-render, even if setState was not explicitly called.

When to use it: Use this function when you are bypassing the setState API to update custom global Lua tables. Because the C++ engine cannot automatically detect changes inside standard Lua tables, markDirty() acts as a manual override to tell the engine that it needs to rebuild the VDOM on the next tick.

Examples

1. Simple Counter

A button that increments a number when clicked.

lua
local el = require("utils.core.elements")

-- Define global state keys to avoid typos
local KEY_COUNT = "counter_val"

function App()
    -- 1. Retrieve current state (defaulting to 0)
    local count = useState(KEY_COUNT, 0)

    return el.VBox({
        style = { 
            justifyContent = "center", 
            alignItems = "center", 
            gap = 20 
        },
        children = {
            -- Display the count
            el.Text({
                text = "Count: " .. tostring(count),
                style = { fontSize = 32 }
            }),
            
            -- Button to increment
            el.VBox({
                style = { 
                    BGColor = "#007ACC", 
                    padding = 10
                },
                onClick = function()
                    -- 2. Update state (triggers re-render)
                    setState(KEY_COUNT, count + 1)
                end,
                children = {
                    el.Text({ text = "Increment", style = { color = "#FFF" } })
                }
            })
        }
    })
end

2. Toggle Switch

A boolean state to toggle visibility or appearance.

lua
function App()
    local isOn = useState("toggle_switch", false)
    
    return el.VBox({
        onClick = function()
            setState("toggle_switch", not isOn)
        end,
        style = {
            w = 100, h = 50,
            BGColor = isOn and "#00FF00" or "#FF0000", -- Conditional Styling
            justifyContent = "center",
            alignItems = "center"
        },
        children = {
            el.Text(isOn and "ON" or "OFF")
        }
    })
end

3. Manual Re-renders with markDirty

Managing state in a custom global table without using setState.

lua
local el = require("utils.core.elements")

-- A custom global state table
local GameState = { score = 0 }

function App()
    return el.VBox({
        children = {
            el.Text("Score: " .. GameState.score),
            el.Button({
                text = "Add Points",
                onClick = function()
                    -- Update the Lua table directly
                    GameState.score = GameState.score + 10
                    
                    -- Tell Vulpis to redraw the UI to reflect the new score
                    vulpis.markDirty()
                end
            })
        }
    })
end

Important: Global Namespace

Unlike React, where state is isolated per-component instance, Vulpis state is stored in a global map accessed by string keys.

The Implications:

  1. Shared State: If two different components use useState("count"), they will share the exact same value. Updating one will update the other.
  2. Naming Convention: To avoid conflicts, it is best practice to namespace your keys.
  • useState("active") (Too generic)
  • useState("navbar_active_tab")
  • useState("settings_volume_level")
lua
-- Good Practice: Unique keys for distinct components
local function CounterA()
    local val = useState("counter_A", 0)
    -- ...
end

local function CounterB()
    local val = useState("counter_B", 0) -- Different key = Independent state
    -- ...
end