Skip to content

Buttons that remember

A view's fields last from click to click and across restarts, and you store none of them. They travel in the message.

Fields are the state

Every annotated field is saved. A new view starts from the defaults.

@risa.register(name="scoreboard")
class Scoreboard(risa.View):
    home: int = 0
    away: int = 0

    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay(f"## Home {self.home} : {self.away} Away"),
            ui.Row(
                ui.Button(self.score_home, label="Home +1"),
                ui.Button(self.score_away, label="Away +1"),
            ),
        )

In Discord

Risa BotAPPToday at 12:00

Home 3 : 1 Away

Home +1Away +1

To start somewhere else, pass values, as this preview does: client.build(Scoreboard(home=3, away=1)).

Each handler sees all the fields, whichever button was clicked, and ctx.rerender() draws the message from the new values.

@risa.handler
async def score_home(self, ctx: risa.ComponentContext) -> None:
    self.home += 1
    await ctx.rerender()

@risa.handler
async def score_away(self, ctx: risa.ComponentContext) -> None:
    self.away += 1
    await ctx.rerender()

After a click on Home +1

Risa BotAPPToday at 12:00

Home 4 : 1 Away

Home +1Away +1

What a click goes through

No view object waits in your bot between clicks. risa builds one for each click and fills it with the state from the message.

The path of a click

  1. 1ClickThe button sends its id to the bot.1a+%@j_!9d!1Z2$}_XMF$w0Fna%
  2. 2Read the idThe id names the view and the handler.
  3. 3Find the viewrisa finds Scoreboard, which add_view gave it.
  4. 4Unpack the stateThe message holds home = 3, away = 1.
  5. 5Run the handlerscore_home changes the fields.
  6. 6RerenderThe button gets a new id, with the new state.1a+%@j_!9d!1Z2$}_XMF$w0g?m(

The state comes from the message and the class from add_view, which your code calls at every start, so a restart changes nothing.

Try it.

Stop the bot between two clicks on Home +1. A click while it is down fails in Discord, and the next click after the restart carries on from the old score.

Where the state sits

risa writes the state into the hidden id of a button, its custom_id. Discord stores these ids with the message and sends them back with every click. These are the scoreboard's real ids, before and after a click on Home +1:

What the buttons hold

home = 3, away = 1

Home +11a+%@j_!9d!1Z2$}_XMF$w0Fna%
Away +11a+%@j_!Lc#!

home = 4, away = 1

Home +11a+%@j_!9d!1Z2$}_XMF$w0g?m(
Away +11a+%@j_!Lc#!

Yellow routes the click to its view and handler. Pink is the state.

The state fits on Home +1, so Away +1 holds only its route. A click on Away +1 still finds the score, because Discord delivers the whole message with the click. State that does not fit on one button spreads over the others.

A click changes only the id that holds the state. Someone whose client still shows the old Home +1 gets a failed click and can click again.

The score is not secret.

Anyone who can see the message can read what the buttons hold, so keep secrets somewhere else.

What a field can hold

A view is a msgspec.Struct, and a field can be anything msgspec encodes: numbers, strings, enums, lists, dicts, other Structs. It all has to fit in the message, which has room for about 60 bytes on one button and about 70 more for each further one, with the default codec. Where state lives shows what you can do when a view needs more.

For what should be read fresh instead of saved, such as a database row, mark the field risa.Prop[T] and fill it in load(), as Fresh data shows.

Full file
"""Two fields and two buttons: the state travels in the message."""

from __future__ import annotations

import os

import hikari

import risa
from risa import ui


@risa.register(name="scoreboard")
class Scoreboard(risa.View):
    home: int = 0
    away: int = 0

    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay(f"## Home {self.home} : {self.away} Away"),
            ui.Row(
                ui.Button(self.score_home, label="Home +1"),
                ui.Button(self.score_away, label="Away +1"),
            ),
        )

    @risa.handler
    async def score_home(self, ctx: risa.ComponentContext) -> None:
        self.home += 1
        await ctx.rerender()

    @risa.handler
    async def score_away(self, ctx: risa.ComponentContext) -> None:
        self.away += 1
        await ctx.rerender()


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )
    client = risa.client_from_app(bot)
    client.add_view(Scoreboard)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!scoreboard":
            rendered = await client.build(Scoreboard())
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()