Streamlit: The 'Good' Way

Hi everyone, I’ve been working quite a bit with streamlit.io lately and I found some real bad practices and code smells in the official docs :disappointed_relieved:.

Because of that I decided to use Streamlit in a completely different way using the best code practices, everything tested and composable.

Using the library as a scripting language with files of hundreds of lines, with a lot of complexity and coupling it’s a really bad and it should be avoided

The idea is to create a base class called Component, this class has a render method in which you call Streamlit. Using classes we can inject the dependencies in the constructor and because of that make the components super easy to test.

from abc import ABC, abstractmethod


class Component(ABC):
    @abstractmethod
    def render(self, *args, **kwargs) -> None:  # type: ignore
        raise NotImplementedError

Using classes allow us too to create reusable components that can be use multiple times across the application.

For example imagine you have a form that makes an HTTP call to an external service:

from collections.abc import Callable

import streamlit as st
import streamlit_pydantic as sp
from pydantic import BaseModel

from src.component import Component


class Form(Component):
    def __init__(self, callback: Callable, model: type[BaseModel]) -> None:
        self.callback = callback
        self.model = model

    def render(self) -> None:
        data = sp.pydantic_form(key=__name__, model=self.model)
        if data:
            self.callback(data.model_dump())

With this approach you can isolate the component from the external call, test it without any problems and also develop the service which makes the HTTP call in a completely independent way.

Use the component is as easy as this:

class ExampleModel(BaseModel): 
    some_text: str
    some_number: int
    some_boolean: bool


 def _callback(data: dict) -> None:
   st.json(data)

form = Form(self._callback, ExampleModel)
form.render()

If you want to know more about it you can see the main components deployed at https://clean-components.streamlit.app, you can also can see the code.

Hope this is useful to you!

3 Likes

Why is this easier / better than the functional approach used by streamlit? To create an object at every run just to call the render method aftewards, why is that easier than just calling a function?

From my experience, the docs are just meant to be simple examples, so I think a lot of people do use OOP with Streamlit

Although I do this, there are two benefits to using the functional version of Streamlit:

  • It’s hard to use Streamlit’s caching methods with class methods
  • If you repeat a method that includes a Streamlit widget, if that method/widget is repeated, you may get a key error, unless you specify the key name and alter it for each call to the method

I greatly agree with the posters sentiment that as Streamlit apps grow, they should ideally be modularized and split across functions and files. (I do that modularization mostly functionally, but sometimes sprinkle in a bit of OOP.) The fact that Streamlit apps can evolve from simple scripts into well structured apps flows from that very property of Python itself and is one of the great strengths of Streamlit IMHO.

2 Likes

This approach is more testeable as with classes you can inject another components/services and mock them.

Calling a function it’s easier if the function is simple and small, having a python file with more than 400 lines of code that does a lot of things it’s a mess IMHO.

Thanks @msquaredds.

You can have both benefits using OOP and also make them more testeable, so why not using it xD?

Thanks!

I agree with you, but the reality seems to be completely different…you can see as a random example here, one of the most rated Streamlit apps, everything in one file with more than 400 hundred lines. This is not easy to follow, testeable and reusable.

Most of the people start using the framework as the docs say and they never ever question themselves about it (I saw it everyday at work).

OOP definitely has its place, espicially to model objects. But streamlit components really are functions - and nothing prevents one from making separate files for each new function / component. And if you have to instantiate wrapper object every time and then call it, it seems like you are shoehorning a function into an object, which has been done for a long time - especially in the Java world. But there is not need to make an object to make a function small and not part of a single long source file. And why isn’t a single function just as testable as a function in an object (a method)?

If you have a function/method which makes an HTTP call to en external API how do you test that the call is ok and the response is well handle? Probably using the unittest patch method, but it’s a mess and hard to maintain.

The idea of using OOP is to extract that logic outside (ex create a service/client/repository which only responsibility is to make the HTTP calls), test it independently and inject it into the object. With that you can decouple UI from the rest of the app, making everything very easy to test.

Regarding the ‘shoehorning’ I didn’t have any issue related with that, using OOP you can control the UI render in a quite easy way IMHO.