← Advanced tutorial: build a routed reading list

3. Replace src/main.luau

Navigation · State · Data · Host · about 30 minutes
complete routed and persisted application

One router owns navigation, one versioned snapshot carries the router plus filter, and RequestIds correlate asynchronous storage completions.

When to use it

Replace the entire generated entry module. Keep encode and restore near each other so snapshot version, fields, validation, and route restoration remain auditable.

  • Navigation.compile validates note_id before a route can enter the stack.
  • State.decode checks framing and version; Data.decode then checks the decoded field shapes; the application finally admits only known filter values.
  • router.restoreEncoded validates every restored route before replacing the active stack.
  • app.resolve ignores unknown RequestIds and clears known requests before applying their result.
  • Rendering reads current state; storage effects start only from admitted button actions.

Focused example

--!strict

local Data = require("luastra/data")
local Host = require("luastra/host")
local Navigation = require("luastra/navigation")
local State = require("luastra/state")
local UI = require("luastra/ui")

local routes = Navigation.compile {
    { name = "library", path = "/" },
    {
        name = "note",
        path = "/notes/:note_id",
        params = { note_id = { type = "integer", minimum = 1, maximum = 99 } },
    },
}

local router = Navigation.createRouter {
    compiler = routes,
    initial = { name = "library", params = {}, query = {} },
}

local snapshotSchema = Data.object({
    navigation = Data.string({ minBytes = 5, maxBytes = 4096 }),
    filter = Data.string({ minBytes = 3, maxBytes = 9 }),
})

local App = require("luastra/app")
local Root = {}
local filter = "all"
local message = "Nothing saved yet"
local pending: { [number]: string } = {}

local function encodeSnapshot(): string
    return State.encode(1, {
        navigation = router.encode(),
        filter = filter,
    })
end

local function track(id: number, operation: string)
    pending[id] = operation
end

function Root.restore(payload: string): boolean
    local decoded = State.decode(payload, 1)
    if not decoded.success then return false end

    local checked = Data.decode(snapshotSchema, decoded.fields)
    if not checked.success then return false end
    local fields = checked.value :: { [string]: any }
    if fields.filter ~= "all" and fields.filter ~= "favorites" then return false end

    local restored = router.restoreEncoded(fields.navigation)
    if not restored.success then return false end
    filter = fields.filter
    return true
end

function Root.render(): UI.Node
    local current = router.current()
    local location = router.currentLocation()
    return UI.Screen {
        id = "reading-list",
        documentTitle = "Routed reading list",
        UI.Column {
            id = "reading/content",
            width = "content",
            padding = "responsive",
            gap = "md",

            UI.Text { id = "reading/title", text = "Routed reading list", variant = "title" },
            UI.Text { id = "reading/route", text = "Route: " .. current.name .. " (" .. location .. ")" },
            UI.Text { id = "reading/filter", text = "Filter: " .. filter },
            UI.Text { id = "reading/message", text = message, role = "status" },
            UI.Actions {
                id = "reading/navigation",
                UI.Button { id = "reading/open", text = "Open note 7", onTap = "reading.open" },
                UI.Button {
                    id = "reading/back",
                    text = "Back to library",
                    onTap = "reading.back",
                    disabled = not router.canBack(),
                },
                UI.Button { id = "reading/filter-toggle", text = "Toggle filter", onTap = "reading.filter" },
            },
            UI.Actions {
                id = "reading/storage",
                UI.Button { id = "reading/save", text = "Save view", onTap = "reading.save" },
                UI.Button { id = "reading/load", text = "Restore view", onTap = "reading.load" },
            },
        },
    }
end

function Root.handle(action: string, target: string, _value: string)
    if action == "reading.open" and target == "reading/open" then
        local result = router.push { name = "note", params = { note_id = 7 }, query = {} }
        message = result.success and "Opened note 7" or "Could not open note"
    elseif action == "reading.back" and target == "reading/back" then
        if router.back() then message = "Returned to library" end
    elseif action == "reading.filter" and target == "reading/filter-toggle" then
        filter = if filter == "all" then "favorites" else "all"
        message = "Filter changed"
    elseif action == "reading.save" and target == "reading/save" then
        track(Host.storageSet("reading-list-view", encodeSnapshot()), "save")
        message = "Saving…"
    elseif action == "reading.load" and target == "reading/load" then
        track(Host.storageGet("reading-list-view"), "load")
        message = "Loading…"
    elseif action == "reading.restore" and target == "app" then
        message = if Root.restore(_value) then "View restored" else "Saved view is invalid"
    end
end

function Root.resolve(
    id: number,
    success: boolean,
    payload: string,
    code: string,
    _errorMessage: string
)
    local operation = pending[id]
    pending[id] = nil
    if operation == nil then return end
    if not success then message = operation .. " failed: " .. code return end
    if operation == "save" then message = "View saved"
    elseif Root.restore(payload) then message = "View restored"
    else message = "Saved view is invalid" end
end

function Root.snapshot()
    return {
        name = router.current().name,
        location = router.currentLocation(),
        filter = filter,
        message = message,
        encoded = encodeSnapshot(),
    }
end

return App.compose {
    render = function(_outputs) return Root.render() end,
    handle = Root.handle,
    resolve = Root.resolve,
    snapshot = Root.snapshot,
}

Complete inventory

View all pages in Advanced tutorial: build a routed reading list
← Previous: 2. Replace luastra.jsonNext: 4. Replace tests/smoke.luau →