Schorle: Testing the Waters with a Python Server-Driven UI Kit

Ivan Trusov
7 min readJan 28, 2024

--

Preface

I’ve built a small, and not-yet-polished asynchronous framework in Python for server-side UI development, called schorle . It lacks a lot of features of mature frameworks, but it has several things that are conceptually different from the existing solutions.

In this blog post, I would like to consider these differences, describe the concepts, and maybe spark some community interest. If after reading you’ll feel the urge to try out (or maybe even contribute), take a look at the repo with the source code.

Existing approaches in JS and Python

Modern web UIs inevitably use one of the following approaches at their core:

  • CSR (Client-Side Rendering)
  • SSR (Server-Side Rendering)

Examples of CSR frameworks are React, Vue, and Svelte. These are great technologies that deliver an uncompromised level of UI controls and convenience for the developers. However, from a backend developer point of view, they require not only knowing the basics of JS (or TS) but also quite a good understanding of how the browser works, loads the pages, etc.

These frameworks are reaching out to the server side, e.g. to request data or interact with the server. To perform such interactions, developers on the front-end side use browser APIs like fetch to retrieve and push data back to the server.

On the other hand, there is the SSR approach, when the server renders the whole page into an HTML payload and returns it as a response to the request from the browser.

Indeed all of these frameworks support SSR in some or another way, and there is a particularly interesting feature in the Next.JS framework called ISR.

Such cool features of the JS ecosystem show a valid path for Server-Side applications in other languages, and there are many ways to do this in Python (the language I’m particularly used to).

Here are some examples of how this could be done:

  • Rendering HTML pages from Jinja templates (e.g. in Flask, Django, FastAPI)
  • Using powerful UI frameworks such as Dash and Streamlit

However, I see three missing things in these approaches in Python:

  • Async and incremental page updates (with a strong accent on async)
  • Strong typing for the UI elements
  • Simple multi-page routing, auth, and other server-side features
  • Well-typed state management

With these ideas in mind, I made a small PoC project schorle — and here is my conceptual vision of what a good Server-Driven UI kit in Python might look like.

Concepts

When it comes to writing a UI application, I’ve considered 3 main things that need to be covered:

  • Elements and layout of the pages
  • Reacting to client-side events
  • Reacting to server-side state changes

As per the list of “potential features” described above, I also would like to extend these definitions to:

  • Well-typed elements and layouts, with the possibility of re-using them
  • Asynchronous reactivity for client-side events
  • Asynchronous reactivity for server-side state changes

Let’s consider these 3 features in a simple example — a UI with 2 buttons and a shared state between them:

Sample UI app built with Schorle

As we can see, this app has all three concepts in place:

  • We have a layout of elements
  • Two of these elements are reactive to the client-side actions
  • One element is a “receiver” that changes when the server-side state is changed.

Starting from the layout, let’s see how it’s managed in Schorle:

from __future__ import annotations

from schorle.app import Schorle
from schorle.effector import effector
from schorle.elements.button import Button
from schorle.elements.html import Div
from schorle.elements.page import Page, PageReference
from schorle.reactives.classes import Classes
from schorle.reactives.state import ReactiveModel
from schorle.reactives.text import Text

# snip

class DecrementButton(Button):
text: Text = Text("Decrement")
page: PageWithButton = PageReference()
classes: Classes = Classes("btn-error")
# snip


class Buttons(Div):
classes: Classes = Classes("space-x-4 flex flex-row justify-center items-center")
increment: Button = Button.factory(text=Text("Increment"), classes=Classes("btn-success"))
decrement: DecrementButton = DecrementButton.factory()
page: PageWithButton = PageReference()
# snip


class CounterView(Div):
# snip

class PageWithButton(Page):
counter: Counter = Counter.factory()
classes: Classes = Classes("space-y-4 flex flex-col justify-center items-center h-screen w-screen")
buttons: Buttons = Buttons.factory()
counter_view: CounterView = CounterView.factory()

In the framework, elements are represented as a small subclass around pydantic BaseModel class. This provides a convenient way to introduce new elements via subclassing them, as well as an interface to combine them into more complex structures (e.g. nested Div with two buttons as per the code above).

Elements have two special fields, namely text and classes . These fields provide a convenient way to change the text and classes on the element.

Now, let’s add some reactivity to these components:

class DecrementButton(Button):
text: Text = Text("Decrement")
page: PageWithButton = PageReference()
classes: Classes = Classes("btn-error")

async def _switch_off(self, counter: Counter):
if counter.value <= 0:
await self.classes.append("btn-disabled")
else:
await self.classes.remove("btn-disabled")

class Buttons(Div):
classes: Classes = Classes("space-x-4 flex flex-row justify-center items-center")
increment: Button = Button.factory(text=Text("Increment"), classes=Classes("btn-success"))
decrement: DecrementButton = DecrementButton.factory()
page: PageWithButton = PageReference()

async def before_render(self):
self.increment.add_callback("click", self.page.counter.increment)
self.decrement.add_callback("click", self.page.counter.decrement)

class CounterView(Div):
page: PageWithButton = PageReference()

async def update(self, counter: Counter):
await self.text.update(f"Clicked {counter.value} times")

With this piece of code, we add reactivity to the following UI elements:

  • The DecrementButton will become disabled as soon as the counter is equal to or less than zero
  • The counter view needs to be updated with the new value of the counter
  • Both buttons shall be reactive and therefore we add callbacks to them.

This code is indeed not runnable, because we’re missing a class that represents the Counter itself. Let’s introduce it as follows:

class Counter(ReactiveModel):
value: int = 0

@effector
async def increment(self):
self.value += 1

@effector
async def decrement(self):
self.value -= 1

This class seems quite strange at first glance. Why do functions that operate on the value have this strange decorator, and more importantly — why do they need to be async?

Indeed this is not a coincidence — this is where we introduce the feedback loop — when something changes on the server, we need to send updates back to the client UI.

We can think of such a server-side change as an effector (function which has effects), and the UI update in this case is an effect.

With this logical concept, we can introduce a way to connect the server-side actions, e.g. functional calls to the UI updates.

In schorle, it’s possible to subscribe other methods to the effectors as follows:

class DecrementButton(Button):
text: Text = Text("Decrement")
page: PageWithButton = PageReference()
classes: Classes = Classes("btn-error")

async def before_render(self):
await self.page.counter.decrement.subscribe(self._switch_off, trigger=True)
await self.page.counter.increment.subscribe(self._switch_off, trigger=True)

async def _switch_off(self, counter: Counter):
if counter.value <= 0:
await self.classes.append("btn-disabled")
else:
await self.classes.remove("btn-disabled")

class CounterView(Div):
page: PageWithButton = PageReference()

async def update(self, counter: Counter):
await self.text.update(f"Clicked {counter.value} times")

async def before_render(self):
await self.page.counter.increment.subscribe(self.update, trigger=True)
await self.page.counter.decrement.subscribe(self.update, trigger=True)

In this example, we say that:

  • Whenever an increment or decrement happens, we need to change the classes of the DecrementButton
  • Whenever an increment or decrement happens, we need to update the text of the counter view.

Now we can put this logic together and have the following code:

from __future__ import annotations

from schorle.app import Schorle
from schorle.effector import effector
from schorle.elements.button import Button
from schorle.elements.html import Div
from schorle.elements.page import Page, PageReference
from schorle.reactives.classes import Classes
from schorle.reactives.state import ReactiveModel
from schorle.reactives.text import Text

app = Schorle()


class Counter(ReactiveModel):
value: int = 0

@effector
async def increment(self):
self.value += 1

@effector
async def decrement(self):
self.value -= 1


class DecrementButton(Button):
text: Text = Text("Decrement")
page: PageWithButton = PageReference()
classes: Classes = Classes("btn-error")

async def before_render(self):
await self.page.counter.decrement.subscribe(self._switch_off, trigger=True)
await self.page.counter.increment.subscribe(self._switch_off, trigger=True)

async def _switch_off(self, counter: Counter):
if counter.value <= 0:
await self.classes.append("btn-disabled")
else:
await self.classes.remove("btn-disabled")


class Buttons(Div):
classes: Classes = Classes("space-x-4 flex flex-row justify-center items-center")
increment: Button = Button.factory(text=Text("Increment"), classes=Classes("btn-success"))
decrement: DecrementButton = DecrementButton.factory()
page: PageWithButton = PageReference()

async def before_render(self):
self.increment.add_callback("click", self.page.counter.increment)
self.decrement.add_callback("click", self.page.counter.decrement)


class CounterView(Div):
page: PageWithButton = PageReference()

async def update(self, counter: Counter):
await self.text.update(f"Clicked {counter.value} times")

async def before_render(self):
await self.page.counter.increment.subscribe(self.update, trigger=True)
await self.page.counter.decrement.subscribe(self.update, trigger=True)


class PageWithButton(Page):
counter: Counter = Counter.factory()
classes: Classes = Classes("space-y-4 flex flex-col justify-center items-center h-screen w-screen")
buttons: Buttons = Buttons.factory()
counter_view: CounterView = CounterView.factory()


@app.get("/")
def get_page():
return PageWithButton()

I’m not entirely convinced that this is good enough (I assume some simplifications might still be applied), but I see some good start here:

  • It follows the imperative paradigm of Python programming, explicitly defining what should be changed and when.
  • Functions that have side effects are defined explicitly, and the side effects are managed explicitly, almost following the Zen of Python in the concepts of Explicit is better than implicit .
  • Elements, effects, and functions are well-typed and the behavior is predictable.

Logically, the flow of events looks like this:

Under the hood, the following technologies are used:

  1. HTMX on the client side to define triggers and communicate messages via WebSockets
  2. Pydantic-based classes to represent elements and state
  3. FastAPI as a server backend
  4. TailwindCSS and DaisyUI for effortless styling

Summary

The project is currently in a PoC state, meaning that I’m looking for more feedback and ideas from the Python community on how such things could be done better. Share your thoughts in the comments, or maybe even try creating some apps with it, and let me know how that feels from a developer perspective.

--

--

Ivan Trusov

Senior Specialist Solutions Architect @ Databricks. All opinions are my own.