We recently launched Rio, our new framework designed to help you create web and local applications using just pure Python. The response from our community has been overwhelmingly positive and incredibly motivating for us.
With the praise has come a wave of curiosity. The most common question we’ve encountered is, “How does Rio actually work?” If you’ve been wondering the same thing, you’re in the right place! Here, we’ll explore the inner workings of Rio and uncover what makes it so powerful.
We will explore the following topics:
Among the numerous classes and functions in Rio, one stands out as indispensable: rio.Component
. This base class is omnipresent throughout the code, forming the foundation for every component in an app, including both user-defined and built-in components.
Components in Rio have several key responsibilities. Primarily, they manage the state of your app. For instance, when a user enters text into a rio.TextInput
, you'll need to store that value. Simply asking for user input without saving it won't make for a satisfying user experience. :)
To streamline value storage, all Rio components are designed to automatically function as dataclass
es.
Dataclasses in Python help reduce boilerplate code for many simple classes. For example, if you need to store information about a customer, without using dataclasses, your code might look like this:
class Cat:
def __init__(self, name: str, age: int, loves_catnip: bool) -> None:
self.name = name
self.age = age
self.loves_catnip = loves_catnip
This approach is functional but quite verbose. Notice how each attribute name must be repeated three times: once as the function parameter, once as the class attribute, and once to assign the parameter value to the attribute. The redundancy becomes even more cumbersome when inheritance is introduced:
class Animal:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
class Cat(Animal):
def __init__(self, name: str, age: int, loves_catnip: bool) -> None:
super().__init__(name, age)
self.loves_catnip = loves_catnip
class Dog(Animal):
def __init__(self, name: str, age: int, is_good_boy: bool) -> None:
super().__init__(name, age)
self.is_good_boy = is_good_boy
Notice how often we had to write same the attribute names. This repetition is so common that the developers of Python introduced dataclasses as a simpler alternative. Here's how the same scenario looks using dataclasses:
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int
@dataclass
class Cat(Animal):
loves_catnip: bool
@dataclass
class Dog(Animal):
is_good_boy: bool
This approach is much simpler and requires significantly less effort while achieving the same result. Additionally, dataclasses offer extra benefits, such as an automatic comparison function (amongst others), allowing us to easily check if two instances are identical.
Since Rio components need to store values and all inherit from rio.Component
, it made perfect sense to make them all dataclasses. This is why Rio components always come with the same, familiar structure:
class CatInput(rio.Component):
name: str
age: int
loves_catnip: bool
def build(self) -> rio.Component:
return ...
This structure should look familiar: attributes are defined at the top, there's no __init__
function, and a build
method is used to create the component's user interface. Whenever any of the attributes change, the build is called again, updating the component accordingly.
This raises the question: how does Rio detect when a component changes?
As we've just seen, one of the reasons Rio components are dataclasses is convenience. Notice how in the dataclass
version of Animal
, we are explicitly informing Python which fields the class has, and their types. For example, we're writing name: str
. Because of this, Python knows exactly that each animal will have a field named name
, and that the value of that field will always be a string.
Compare that to the regular class. We're telling Python that the __init__
function accepts a parameter called name
, and we copy that value into the class, but we never tell Python about it. The interpreter has no clue which fields the class comes with, and what their datatypes are.
Explicit fields are not only a good idea because they help out other developers reading your code, but because libraries such as Rio can read them as well. Because we have explicitly listed our fields, Rio now knows which values the class has, and will watch those for changes for us. How? Simple, using __setattr__
.
Python classes can have magic methods. You've seen these before, they're methods starting & ending with two underscores. __init__
is one of those methods.
Another method is __setattr__
. Python calls this function each time you assign a value to a class's attribute. You can use this to store values in a JSON value, whenever they're assigned to. Or maybe you're debugging code and want to print
new values when they change. Or, of course, maybe you want to be informed whenever an attribute changes, so you can rebuild the UI. This is exactly what Rio does.
class MyClass:
def __setattr__(self, name, value):
print(f"Setting {name} to {value}")
self.__dict__[name] = value
This is why it's crucial to always assign a new value to a component's attribute when you change it. If you modify an attribute without assignment (for example, by appending it to a list), Python won't trigger __setattr__
, and Rio won't know that your component needs to be updated. If your component seems unresponsive, check your code to ensure you are assigning new values to the attributes.
So, Rio has detected that your component has changed, build
has been called, and a new set of components has been created. What happens next? Should Rio simply discard the old components and replace them with new ones? No!
The previous components may contain a lot of important state that needs to be preserved. Imagine this scenario: a user has entered the perfect cat name into your app, toggled some switches, and selected an item from a dropdown menu. Then, they trigger an event, perhaps by pressing a button or resizing the window.
Your event handler is called and updates a component. If Rio were to replace the old components with the new ones, that precious cat name would be lost forever.
Instead, Rio uses a set of techniques called diffing & reconciliation. The first step is to find matches between the old and new components. For instance, if the build
function previously returned a TextInput
and returns a TextInput
again, it's likely the same component with an updated state. This process of finding matching pairs is known as diffing. The basic idea is simple: Rio recursively walks the new and old output of the build function.
If it encounters two components of the same type, they are considered a match. Rio then continues the search into the component's children, looking for matches there as well. If two components do not match, the recursive search stops and the component is considered new. This "structural" matching works well in most cases and identifies most matches.
However, there are situations where it isn't sufficient. For example, imagine a list of many components. Depending on the user's actions, new components may be added or removed, changing the order of existing entries. If we were to simply match the first component in the old list with the first in the new list, we'd quickly encounter problems. To avoid this, Rio uses a second technique based on explicit keys.
You might have noticed a key
parameter in a Rio component before. This optional parameter is common to all components, and some even require you to set one.
If a component in the build
output has the same key as a component in the previous output, they are always considered a match, regardless of changes in their parent or overall position. This allows Rio to track components that have moved and update them accordingly.
Now, with matching pairs of components identified, the reconciliation step begins. Rio compares the paired components and determines which attributes to retain from each version:
Attributes that you have explicitly set on the new component always take priority.
Attributes that have changed on the previous component after their initialization are retained.
Finally, any other attributes are taken from the new component.
These rules strike a good balance, always honoring new values while preserving the previous state if it was likely intentionally set.
This concludes the first installment of our deep dive series. The next part will be available in the coming weeks. In the meantime, join our Discord server! You can showcase the cool apps you've built or get help if you're still early in your journey.
Source: https://github.com/rio-labs/rio