Skip to content

Where state lives

A view's state sits in its message until the message runs out of room. This chapter shows what you can do then, and how to choose between the message and a store.

What you can do

Smallest step first. The last column counts for the poll below, which fits twelve voters as it is:

Step What you write Room for voters
Read it fresh Mark the field risa.Prop[T] and fill it in load() from your database The field takes none
Another codec state=risa.InMessage(codec=risa.codecs.BASE32768), with the radixly extra 33 voters, or 30 with URO14
A store state=risa.InStore(ttl=...), as Stores and TTL shows No size limit

A codec change leaves messages already sent working, since each id names its codec. A prop or a move to a store retires them: a click on one fails in Discord, unless a class at the old version answers it.

A poll that outgrows its message

This poll counts the votes and keeps the id of everyone who voted, so that nobody votes twice.

@risa.register(name="poll")
class Poll(risa.View):
    question: str
    yes: int = 0
    no: int = 0
    voters: list[int] = msgspec.field(default_factory=list[int])

Yes and No each ask this method first, and it adds the voter's id to the list.

async def _first_vote(self, ctx: risa.ComponentContext) -> bool:
    if ctx.user.id in self.voters:
        await ctx.respond("You already voted.", ephemeral=True)
        return False
    self.voters.append(ctx.user.id)
    return True

A second vote from the same person

Risa BotAPPToday at 12:00

You already voted.

Only you can see this·Dismiss message

The limit

Two buttons hold 140 bytes, framing included, and each voter takes 9 of them. With the question and the counts, twelve voters fit:

12 voters

Risa BotAPPToday at 12:00

Pizza on Friday?

Yes: 7No: 5

The thirteenth does not. client.build raises StateOverflowError, and the error lists what each field takes:

13 voters: what client.build raises

view 'poll' needs 147 bytes of durable state, but its 2 interactive components carry only 140 with codec 'base85':
    voters    118 bytes
    question   17 bytes
    yes         1 byte
    no          1 byte
    framing    10 bytes
Make room by marking per-dispatch fields risa.Prop[...], adding components, shortening fields, using a denser codec (state=risa.InMessage(codec=risa.codecs.BASE32768) with hikari-risa[radixly]), or moving the view to state=risa.InStore(...).

A running poll gets this error from ctx.rerender() in the thirteenth voter's click. Nothing has been written yet, so the message keeps twelve votes. If the handler lets the error through, risa logs it and the click fails in Discord. This poll catches it and answers instead:

async def _save(self, ctx: risa.ComponentContext) -> None:
    try:
        await ctx.rerender()
    except risa.StateOverflowError:
        await ctx.respond("This poll is full.", ephemeral=True)

The thirteenth voter's click

Risa BotAPPToday at 12:00

This poll is full.

Only you can see this·Dismiss message

self still holds the thirteenth vote. When the handler returns, risa tries to save it once more, finds that it still does not fit, and leaves it at a DEBUG line, since the handler has dealt with it. A handler that goes on to change the poll after the except saves that change together with the thirteenth vote, which does not fit either, so it takes the vote back first.

At twelve voters the state fills both buttons:

What the buttons hold

Twelve voters

Yes: 71a*_|V|0b=!{Z2*dGR!{%{0F<y$X?l8LAa8CUMsjInVR=6X1)R?k1sIFTy+8ra5(OBG$-Q3i&k_X~i^;ux-_H^S7>mihoX*b@1sD
No: 51a*_|V|0SU#xi^;vaz0VQ_7>mih+N{qK1sIFTz4@EZ5(OBG$-Wqh&k_X~i^;w>dCw9B7>miiR%p)>1sIFTzI9j65(OBG$-b0E

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

The voters are not secret.

render() shows only the totals, but the voters' ids travel in the pink part. Anyone who can see the message can read who voted.

Each vote also changes these ids. When tested, Discord refused a click on an old id, so of two people voting at the same moment, the second can get a failed click and has to click again.

Another codec

A codec decides how risa writes a view's state into the ids of its buttons. Discord allows 100 characters per id, however many bytes each character takes, so a codec with more characters to choose from fits more into each one. The default, BASE85, uses 85 ASCII characters and writes 6.4 bits with each. BASE32768 chooses from 32,768 characters and writes 15. When tested, ids of 100 such characters came back from Discord byte for byte.

Codec Bits per character One button holds The poll fits A bound str, in ASCII characters
BASE85 6.4 70 bytes 12 voters 68
BASE32768 15 165 bytes 33 voters 159
URO14 14 152 bytes 30 voters 147

The bytes include 9 of framing per view, so the default codec leaves about 60 for your state on one button, and about 70 for each further one. The last column is for a button with no state, as in What can be an argument.

To switch, install the radixly extra:

uv add "hikari-risa[radixly]"
pip install "hikari-risa[radixly]"

Then name the codec on the view with risa.InMessage. Nothing else in the view changes.

@risa.register(name="poll", state=risa.InMessage(codec=risa.codecs.BASE32768))
class Poll(risa.View):
    question: str
    yes: int = 0
    no: int = 0
    voters: list[int] = msgspec.field(default_factory=list[int])

Compare the ids with the ones above: the same twelve voters now fit on the first button, and the second has no state at all.

What the buttons hold

The same twelve voters in BASE32768

Yes: 71w*_|V|0b=!m峠䣛優ҠҤ礡䜒ꄚ困䈛橨趆熃ᨤꔎᣜ跩ᄦ㞹䈔Ү抨⠱⩉蔏抓袠矨蒭鱗旾㡅⬅飏姹鍑㙨袏㦻戝跩ᄦ㞹䈝篆抨⠱⩉蕜赓袠矨蒭黂㯞㡅⬅飏浏䌱㙨袏㦼糈跩ᄦ㞹䈧儞抨⠱⩉薪㟿
No: 51w*_|V|0SU#!

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

You don't raise version, and messages already sent keep working. Each id names its codec, so risa reads an old message in the codec it was written in, and writes it in the new one at its next rerender. A poll that was full gets its room with the next vote.

BASE32768 holds the most, so take it unless you have a reason not to. URO14 holds a little less and writes only characters from one CJK block. InMessage(codec=...) also takes a Codec of your own.

Keep these in mind:

  • There is still a limit. The 34th voter raises StateOverflowError, where a store has no limit.
  • The state stays readable. The ids look scrambled, but anyone can decode them, so a codec is no encryption.
  • Each vote still changes the ids, so two people voting at the same moment can still get a failed click.
  • The extra has to stay installed. Without radixly, @risa.register raises CodecUnavailableError on a view that names the codec. A click on a message written in it logs an error that names the extra, and the click fails.
  • A codec of your own has to stay registered. Once no view names it, call risa.codecs.register on it in every process that answers clicks. Otherwise risa takes its ids for another bot's and ignores them, so the click fails with no line in your log.
  • Going back can overflow. A message that holds more than the old codec fits fails at its next rerender with StateOverflowError.

A codec is only for the message. InStore has no codec, since its buttons hold a short key.

Message or store

The message, risa.InMessage, is the default. It needs no infrastructure, and the state lives as long as the message does. Move to a store when the view needs one of these:

The view needs A store gives it
Room No size limit
A lifetime A ttl, where state in a message never expires
Data not everyone should read Buttons that hold a random key, with the state out of sight
Many people clicking at once Ids that change only with a bound argument, so a vote leaves them as they are

Where the bot runs counts too, as Several processes shows.

Full file
"""A poll that records who voted, in the message, until the message runs out of room."""

from __future__ import annotations

import os

import hikari
import msgspec

import risa
from risa import ui


@risa.register(name="poll")
class Poll(risa.View):
    question: str
    yes: int = 0
    no: int = 0
    voters: list[int] = msgspec.field(default_factory=list[int])

    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay(f"## {self.question}"),
            ui.Row(
                ui.Button(self.vote_yes, label=f"Yes: {self.yes}"),
                ui.Button(self.vote_no, label=f"No: {self.no}"),
            ),
        )

    @risa.handler
    async def vote_yes(self, ctx: risa.ComponentContext) -> None:
        if await self._first_vote(ctx):
            self.yes += 1
            await self._save(ctx)

    @risa.handler
    async def vote_no(self, ctx: risa.ComponentContext) -> None:
        if await self._first_vote(ctx):
            self.no += 1
            await self._save(ctx)

    async def _first_vote(self, ctx: risa.ComponentContext) -> bool:
        if ctx.user.id in self.voters:
            await ctx.respond("You already voted.", ephemeral=True)
            return False
        self.voters.append(ctx.user.id)
        return True

    async def _save(self, ctx: risa.ComponentContext) -> None:
        try:
            await ctx.rerender()
        except risa.StateOverflowError:
            await ctx.respond("This poll is full.", ephemeral=True)


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(Poll)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!poll":
            rendered = await client.build(Poll(question="Pizza on Friday?"))
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()