========================
== Leon Muscat's Blog ==
========================

Managing State Nicegui

Nicegui

At work I started using Nicegui quite extensively to build various tools based on it. I’ve found that Nicegui is incredibly versatile to quickly create small to medium sized tools that go beyond the standard data presentation that Streamlit or Plotly Dash allow.

Recently, I came across a discussion about effective state management within Nicegui. The problem is that the library does not provide any out-of-the-box solution, so I’ve developed my own over time. I invite you to read the discussion thread, but the essence of my approach is documented below.

Starting out with Nicegui, you can quickly end up with messy code that mixes frontend and backend logic. You end up losing track of the data and application state, which makes maintaining or expanding on the tool unnecessarily difficult.

After many refactoring sprints, I’ve landed with this general structure for tools:

tool/
├── service/
│   └── ...          # Service layer functions
├── ui/
│   └── ...          # High level component classes
├── page.py          # ToolPage class & UI entrypoint
├── state.py         # ToolState class (Signals, Computed, Effects)
└── models.py        # Domain models & DTOs

The Page

The page class is the entrypoint for the tool. It initiates the state, high-level ui components, and communicates with the service layer.

It looks something like this:

class ToolPage:
    """This is a tool."""

    def __init__(self):
        self.state = ToolState()
        self._setup_ui()
        self._rerun_calculation_effect = Effect(self._calculate) # <- We'll talk about this later

        # Register cleanup on disconnect
        ui.context.client.on_disconnect(self.cleanup)

    def _setup_ui(self):
        # Here we initialise our high-level component classes
        # They each receive the state and relevant callbacks
        self.inputs = Inputs(self.state, self._calculate)
        self.chart = Chart(self.state)
        self.table = Table(self.state)

    async def _calculate(self):
        is_running = self.state.is_running()
        if is_running:
            return

        with self.notification_container:
            notification = ui.notification(
                "Running ...", type="info", timeout=None, spinner=True
            )
        try:
            result = await run.io_bound(...)
            self.state.result.set(result)
            notification.message = "Done!"
            notification.type = "positive"
        finally:
            notification.timeout = 1
            notification.spinner = False


    def cleanup(self):
        """Cleanup all resources when client disconnects."""
        # Dispose reactive effects
        self._rerun_calculation_effect.dispose()

        # Cleanup UI components
        self.chart.cleanup()
        self.table.cleanup()

        # Cleanup state
        self.state.cleanup()

State

By now you are probably wondering what this state class is. It’s a declarative and reactive state. That means changes propagate automatically and all state-related logic is centralised. The idea is very similar to state management in reflex.dev, which is another great Python web app framework.

State management requires three basic building blocks:

  1. Signals: Values that change over time that should trigger state changes.
  2. Computed: Transforms Signals. It itself can also be used as another Signal.
  3. Effect: Side effects when Signals change.

A new library that provides exactly that with neat dependency management between these components is reaktiv.

The building blocks are well-illustrated here:

Reaktiv state management

What does that mean in practice? Let’s have a look:

class ToolState:

    # User defined values
    date_filter: Signal[datetime.date]
    text_input: Signal[str]

    result: Signal[ToolResult | None] # ToolResult serves as data transfer object between service layer and frontend
    is_running: Signal[bool]
    has_result: Computed[bool]

    transformed_result: Computed[dict[str, float]]

    def __init__(self):
        # This is where we init the vars and set defaults
        self.date_filter = Signal(datetime.date.today())
        self.text_input = Signal("")

        self.result = Signal(None)
        self.is_running = Signal(False)
        self.has_result = Computed(lambda: self.result() is not None) # <- has_result is automatically updated when result changes!

        self._log_effect = Effect(lambda: print(f"{result=}")) # <- Automatically runs when result changes

        self.transformed_result = Computed(self._a_function)

    # ...

    def cleanup(self):
        self._log_effect.dispose()

Components

Now that the state is defined, we can share an instance of the state with any number of components, which can present said state.

Each component should accept only a state and callbacks. To link user defined values to Nicegui control components, we can use Nicegui’s builtin binding. On the other side to display the state, we can make use of reaktiv Effects to trigger updates.