Contents

Pros and cons of using Streamlit for simple demo apps

Adam Wawrzyński

30 Jan 2023.9 minutes read

Pros and cons of using Streamlit for simple demo apps webp image

What is Streamlit?

Have you ever come across a demo machine learning model with a nice graphical interface? There's a good chance it was written using the Streamlit framework. Streamlit is an open-source framework for rapid prototyping and the creation of visualizations and dashboards in Python without the need for knowledge of front-end technologies such as JS or HTML. With just a few lines of code, you can create dynamic, visually appealing apps that allow users to input data and view the results in real-time. When you run a Streamlit app, the Python code is executed and generates the necessary HTML, CSS, and JavaScript code to display the app in a web browser.

What is it used for?

It is mainly used to develop simple web applications to demonstrate the operation of machine learning models, algorithms, or display data. It has quite a few predefined widgets for entering parameters and presenting various data types - from text to images to audio. These elements are defined in JS, but have an easy interface in Python. Thanks to its ease of use, it allows rapid prototyping and the creation of demonstration applications.

Most of the issues discussed in this article have been implemented in a demonstration application, which can be found at: https://github.com/adam-wawrzynski-softwaremill/demo_streamlit.

unnamed%20%289%29

Pros

Below is what we like the framework for.

Session state

Due to the reactive design of the framework, the application is restarted every time the application state changes, which requires the scene to be updated. To preserve the application state between restarts, a session state is defined. The created application is organized around reading and writing values from the session state. This is a key element of any application created in this framework. The session state is thread-safe, a separate session state is created for each session, so multiple users will not interfere with the application state. Session state is a package global variable and has the interface of a regular Python dictionary.

Reactive widget states

Capturing and updating the status of widgets, such as a checkbox, is straightforward. All you need to do is define a key in the session state dictionary that will store the widget's state, and that's it. We can start building the application logic.

view.py

import streamlit as st

from demo_streamlit.callbacks import sample_index_callback
from demo_streamlit.load_data import load_dataset
from demo_streamlit.settings import Settings

def sidebar_view() -> None:
    """Draw side bar."""
    samples = load_dataset()
    st.sidebar.selectbox(
        "Select sample index:",
        options=list(range(0, len(samples))),
        key=Settings.selected_sample_index,
        on_change=sample_index_callback,
    )

To obtain the value of this widget, we use the key value:

app_state.py

from dataclasses import dataclass

import streamlit as st

from demo_streamlit.settings import Settings

@dataclass
class AppState:
    """An app state model."""

    selected_sample_index: int
    """Selected sample index."""

    message: str
    """UI message."""

def get_app_state() -> AppState:
    """Get AppState dataclass from session state."""
    return AppState(
        selected_sample_index=int(
            st.session_state.get(Settings.selected_sample_index, 0)
        ),
        message=st.session_state.get(Settings.message, ""),
    )

Thread safe singletons and cache

A frequently used feature is the cache, which allows long-running operations to be optimized when updating the application view. The cache mechanism provided by the framework is effortless to use. The @st.cache decorator is used for this, in which we can define, among other things, the time to live parameter and custom objects hash functions. The cache checks the arguments, the body of the function itself and all functions called in it, and external variables used in the function. Due to the reactive design of this framework and the redrawing of the UI with every state change, the use of a cache is essential to ensure acceptable responsiveness and speed of the application.

Below is a code defining a function that lists the objects present in the database based on a query. Due to the execution time, this function has been optimized by using a caching mechanism. The TTL parameter has been set to 10 minutes. Custom hash functions were defined for objects that cannot be cashed by the framework. For complex objects, we return the value None to exclude these objects from the hash calculation.

database.py

@st.cache(
    hash_funcs={
        Collection: lambda _: None,
        database.Database: lambda _: None,
        MongoClient: lambda _: None,
    },
    ttl=600,  # TTL value in seconds
)
def list_items(
    self,
    query: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
        if query is None:
            query = {}

        items: list[dict[str, Any]] = [item for item in self._collection.find(query, sort=[("timestamp_created", -1)])]

        return items

Flexible widget callbacks

The framework gives a great deal of freedom in defining callbacks to widget events. Although there are few events available, they are sufficient in most cases. These are on_click and on_change. In the widget constructor, we can pass a function to the on_click or on_change arguments to be called in response to an event. In addition, we can pass any parameters as a tuple, defined in the args argument.

callback.py

import logging
from typing import Any

import streamlit as st

logger = logging.getLogger(__name__)

def button_callback(index: str, headers: dict[str, Any] | None) -> None:
    """Button on_click callback."""
    logger.info(  # pylint: disable = (logging-fstring-interpolation)
        f"Passed arguments: ({index}, {headers})."
    )

view.py

import streamlit as st
from streamlit.web.server.websocket_headers import _get_websocket_headers

from demo_streamlit.callbacks import button_callback
from demo_streamlit.settings import Settings

def sidebar_view() -> None:
    """Draw side bar."""
    headers: dict[str, str] | None = _get_websocket_headers()
    if headers:
        with st.sidebar.expander("HTTP header"):
            st.json(headers)

    st.sidebar.button(
        label="Log parameters to terminal",
        on_click=button_callback,
        args=(
            st.session_state.get(Settings.selected_sample_index, 0),
            headers,
        ),
    )

After pressing the button, the following information appeared on the terminal:
unnamed%20%2810%29

Query params for storing app state in URL

If multiple users use the application, the application configuration may need to be shared with others. The query_params functionality present in the framework serves this purpose. The st.experimental_set_query_params function takes a dictionary as a parameter, which will be turned into GET parameters in the URL. Similarly, the st.experimental_set_query_params function obtains a dictionary with parameters from the URL. In this way, sending someone the URL with the stored parameters is possible, and when they open the application, they will see the same view. The limitation is the accepted data format: it can only be of type dict[str, list[str]].

Python decorators for state updates

For repetitive operations, it is worth considering the use of decorators. For example, updating query_params could be a decorator to be added to any callback that handles widget state changes.

query_params.py

from functools import wraps

import streamlit as st

def update_query_params() -> None:
    """Updates the URL query params."""
    query_params: dict[str, list[str]] = dict()
    query_params["text_input"] = [st.session_state.get("text_input", "")]
    query_params["checkbox_state"] = [str(st.session_state.get("checkbox_state", False))]
    st.experimental_set_query_params(**query_params)

def refresh_query_params(f):
    """Updates the URL query params after every app state change."""

    @wraps(f)
    def wrapper(*args, **kwargs):
        retval = f(*args, **kwargs)
        update_query_params()
        return retval

    return wrapper

callback.py

from query_params import refresh_query_params

@refresh_query_params
def checbox_callback() -> None:
    ...

 @refresh_query_params
def text_input_callback() -> None:
    ...

Development server mode with monitoring changes in the code

It is simply convenient. You don't have to restart the application every time you change the application source code. By default, streamlit runs applications in developer mode, where the source code is monitored for code changes. Every change is considered, and the app updates its operation on the fly.

Access HTTP request headers

Version 1.14.0 introduced the possibility of obtaining the raw HTTP header that went into the application. This is a useful feature when you want to extract the information you need from the HTTP headers, such as the ID/e-mail of a user authenticated by Google Cloud:

import streamlit as st
from streamlit.web.server.websocket_headers import _get_websocket_headers

headers = _get_websocket_headers()
access_token = headers.get("X-Access-Token")
if access_token is not None:
  # authenticate the user or whatever
  ...

Deployment

Deployment of the application is very simple and thoroughly described in the documentation. The deployment process using Docker, kubernetes or Streamlit Cloud containers is described. All descriptions include sample code snippets so that the user is guided through the entire process by the hand.

Launching an application using Streamlit Cloud only requires providing a link to a GitHub repository. After a few clicks, we have a public URL where we can reach our running application.

Cons

The following describes the framework's drawbacks, which are worth considering when designing an application.

The scene is redrawn every time the state changes

As mentioned earlier, the application runs the application code from scratch every time the UI state changes and all application state is stored in the session state. If the state of the widgets is changed frequently, this can cause the app to refresh unpleasantly often, thus introducing a lot of delay. This feature forces the application to be designed so that as many long-running functions as possible are cached but for some kinds of applications it’s not enough.

No nesting of containers

The lack of nesting of views is a major limitation in the design of an application layout. The problem can occur in more complex application views or when you want to compare two complex objects drawn side by side. Because of this limitation, we are forced to look for alternative solutions that may be less ergonomic.

Session state only with simple key-value pairs

Session state is a very convenient solution for simple key-value pairs, as in the case of storing the state of widgets. In real-world applications, it is more common to deal with complex objects that store the entire application's state. This way, we have one object representing the whole application, and we can enforce the typing of the class fields. Unfortunately, this is not currently possible in the framework. To achieve such an effect, we need to create two functions: to load/save a class from/to the session state.

app_state.py

import streamlit as st
from dataclasses import dataclass

@dataclass
class AppState:
    checkbox_state: bool
    text_input: str

def load_app_state() -> AppState:
    return AppState(
        checkbox_state=st.session_state.get("checkbox_state", False),
        text_input=st.session_state.get("text_input", ""),
    )

def save_app_state(state: AppState) -> None:
    st.session_state["checkbox_state"] = state.checkbox_state
    st.session_state["text_input"] = state.text_input

Duplication of elements in the code when reusing widgets/containers

When we want to compare two objects with each other, we need a set of variables for the two objects in the session state. Because the session state accepts key-value pairs, we either need to store a list of elements for a particular key or create separate keys for the two objects, e.g., text_input_1 and text_input_2. This does not look good and introduces the possibility of error.

Necessity to project elements from the session state

The objects in the session state do not have a defined type, so we are not sure what object will be returned. If we want to be sure, we can use the cast method from the typing package.

cast_example.py

import streamlit as st
from typing import cast, Any

value: list[str]
value = cast(list[str], st.session_state["key"])

Conclusion

Streamlit is a great framework for creating simple demo applications. If the state of the application changes frequently, performance and latency issues can be associated with drawing the scene from scratch each time the state has changed. Another limitation is the inability to nest containers, such as a column, so we have limited options for page layout.

The functionalities provided by the framework, such as session state, cache and widget callbacks, enable the rapid creation of complex application flows.

For more complex applications, You may encounter limitations due to the framework's design. It is worth considering the requirements for the application You are developing and determining whether Streamlit is a suitable candidate. Many similar frameworks are characterized by their complexity and capabilities, a comparison of which can be found here.

Tech review:
Kamil Rzechowski

Blog Comments powered by Disqus.