Theme
Version
GitHub PyPI Discord
On this page

Keep values between calls

An event handler often needs to remember something from the last call: which page of results it showed, which filter is on, how many items it loaded. The handler does not keep anything between calls on its own. In this step, a counter survives from one call to the next, so Python can load a different batch of choices on each click.

Citry keeps such values in the component's State. Citry sends State to the browser with the rendered component, and the browser sends it back with the next call.

Replace the components

Replace components.py with:

from citry_setup import citry_app

from citry import Component
from citry.ext.events import actions

# New in this step: simulate two database query results.
# Based on value from State, we return one of these two
# batches of choices.
CHOICE_BATCHES = (
    ("Ocean", "Forest"),
    ("History", "Science"),
)

def load_choices_from_database(batch: int) -> list[str]:
    choices = CHOICE_BATCHES[batch % len(CHOICE_BATCHES)]
    return list(choices)

class ChoiceButton(Component):
    citry = citry_app

    class Kwargs:
        pass

    class Slots:
        pass

    template = """
      <button
        class="choice-button"
        type="button"
        @click="$emit('select')"
      >
        Choose
        <span
          class="choice-button__label"
          v-text="label"
        ></span>
      </button>
    """

    js = """
      $component({
        props: {
          label: { type: String, required: true },
        },
        emits: ['select'],
      });
    """

class ChoicePicker(Component):
    citry = citry_app

    # New in this step: set the counter for the first render.
    class Kwargs:
        batches_loaded: int = 0

    class Slots:
        pass

    # New in this step: carry the counter between Python calls.
    class State:
        batches_loaded: int = 0

    class Events:
        def load_choices(self, state):
            # New in this step: read and advance the signed State.
            # NOTE: State is passed to the event handler.
            #       You can mutate the State, and it will be
            #       signed and sent back to the client.
            choices = load_choices_from_database(state.batches_loaded)
            state.batches_loaded += 1

            return actions.Dispatch(
                "ChoicePicker:loaded",
                {
                    "choices": choices,
                },
            )

    template = """
      <section class="choice-picker">
        <button
          type="button"
          :disabled="$loading('load_choices')"
          @c-click="load_choices"
        >
          Load choices
        </button>
        <span v-show="$loading('load_choices')">Loading...</span>

        {# New in this step: show the counter from Python. #}
        {# Python renders the starting count inside <output>. #}
        {# `$state` is State in the browser; it updates after #}
        {# each successful Python call, and Vue shows the new #}
        {# value. #}
        <p>
          Sets loaded:
          <output v-text="$state.batches_loaded">
            {{ batches_loaded }}
          </output>
        </p>

        <p v-show="choices.length === 0">
          No choices loaded yet.
        </p>
        <div v-show="choices.length > 0">
          <p>
            Current choice:
            <output
              class="choice-picker__value"
              v-text="choice"
            ></output>
          </p>

          <c-ChoiceButton
            :label="choice"
            @select="setNextChoice"
          />
        </div>
      </section>
    """

    js = """
      $component({
        data() {
          return { choices: [], choice: '' };
        },
        methods: {
          loadChoices(newChoices) {
            this.choices = newChoices;
            this.choice = newChoices[0];
          },
          setNextChoice() {
            const oldChoiceIndex = this.choices.indexOf(this.choice);
            this.choice = this.choices[
              (oldChoiceIndex + 1) % this.choices.length
            ];
          },
        },
        onServerRender({ component, onEvent }) {
          // Citry removes this listener before onServerRender
          // runs again and when the component unmounts.
          onEvent('ChoicePicker:loaded', (detail) => {
            component.loadChoices(detail.choices);
          });
        },
      });
    """

class TutorialPage(Component):
    citry = citry_app

    class Kwargs:
        pass

    class Slots:
        pass

    template = """
      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="utf-8" />
          <title>Reading room</title>
        </head>
        <body>
          <main>
            <h1>Reading room</h1>
            <c-ChoicePicker />
          </main>
        </body>
      </html>
    """

Click β€œLoad choices” twice. The first call loads β€œOcean” and β€œForest”; the second loads β€œHistory” and β€œScience.” β€œSets loaded” goes from one to two. Reloading the page starts over.

Declare State

class Kwargs:
    batches_loaded: int = 0

class State:
    batches_loaded: int = 0

Kwargs are the inputs for one render. State holds the values that event handlers receive on each call. On the first render, Citry fills each State field from the input with the same name. A field with no matching input uses its own default.

When every input should be kept, make State inherit from Kwargs instead:

class State(Kwargs):
    pass

Keep the two classes separate when some inputs should not go to the browser. When names differ or a value must be computed, fill State with state_data(), as Event state shows.

Read and change State

class Events:
    def load_choices(self, state):
        choices = load_choices_from_database(state.batches_loaded)
        state.batches_loaded += 1
        return actions.Dispatch(
            "ChoicePicker:loaded",
            {"choices": choices},
        )

The handler receives State through its state parameter. The first call gets zero, loads batch zero, and sets the value to one. Citry sends the changed State back with the response, so the next call gets one.

Show State on the page

The template reads State through $state:

<output v-text="$state.batches_loaded">{{ batches_loaded }}</output>

Python renders the starting value inside the tag. After each call that changes State, $state.batches_loaded updates, and Vue shows the new value.

The list of choices stays in ordinary Vue data, because Python does not need the browser's current choice on its next call.

Changing State from the browser

Browser code can also assign a whole field, as in $state.batches_loaded = 0. The page shows the new value at once, but the assignment does not send a request: Citry sends the value with the component's next event call (calls sent as GET requests are the exception). You cannot assign a nested value, or a field that State does not let the browser change. See $state for the full rules.

Keep secrets out of State

State is signed, not secret. Citry signs it with CITRY_SECRET so it can tell when someone has changed it, but anyone who opens the page can read the values. Signing also does not identify the user or grant permission.

Never put passwords or API keys in State, and check access inside each event handler. In production, every worker needs the same CITRY_SECRET.

Next steps

Next, handle and validate forms.