Building a React-like Component Framework on Top of Streamlit
As a developer who enjoys exploring the intersection of frontend and backend development, I recently embarked on a journey to build a React-like component framework on top of Streamlit. While Streamlit is known for its simplicity in creating data-driven apps, I wanted to push its boundaries by introducing concepts like components, hooks, and a virtual DOM that React developers are familiar with.
In this post, I’ll share my learning experience, the challenges I faced, and how I implemented a basic framework that mimics React’s useState hook and component system using Streamlit’s existing capabilities.
Table of Contents
- Motivation
- Goals of the Project
- Implementation Overview
- 5. Handling Re-Renders
- Challenges and Solutions
- Final Thoughts
- What’s Next?
Motivation
Streamlit’s auto-reload mechanism and top-down execution model make it ideal for quickly building interactive applications. However, I wanted to experiment with a component-based architecture on top of Streamlit to see if I could build reusable UI elements, manage state more predictably, and understand how React-like patterns could coexist with Streamlit’s execution flow.
Goals of the Project
- Create a custom component-based framework on top of Streamlit.
- Implement a use_state hook for state management, similar to React’s useState.
- Develop a basic Virtual DOM (VNode) structure to represent UI components.
Implementation Overview
Here’s how I structured the framework and tackled each of the goals:
1. Creating a Virtual DOM (VNode)
The core of any React-like framework is its ability to represent the UI as a tree of nodes. I introduced a simple VNode class to represent elements like text, buttons, containers, and more.
# VNode class to represent a virtual DOM node
class VNode:
def __init__(self, type, key=None, props=None, children=None):
self.type = type # Type of element (e.g., "text", "button", "container")
self.key = key # Unique key to identify the node
self.props = props or {} # Properties (e.g., content, label)
self.children = children or [] # Child nodes
def __repr__(self):
return f"VNode(type={self.type}, key={self.key}, props={self.props})"
2. Building a Component System
To build components like React, I created a base Component class that could manage its own state and handle re-renders. I implemented a custom hook called use_state to mimic React’s useState hook.
# Base component class that manages state and triggers re-renders
class Component:
def __init__(self):
self.state = {}
def set_state(self, new_state):
"""
Set the component's state and trigger a re-render through Streamlit.
"""
self.state.update(new_state)
st.experimental_rerun() # Force Streamlit to rerun and apply state changes
3. Implementing use_state
The use_state hook is responsible for managing individual pieces of state for each component. In React, useState updates the state and triggers a re-render of the component. I implemented a similar mechanism using Streamlit’s session_state to persist state values.
import streamlit as st
def use_state(key, initial_value=None):
if key not in st.session_state:
st.session_state[key] = initial_value
return st.session_state[key], lambda new_value: st.session_state.update({key: new_value})
4. Building the Counter Component
To demonstrate the use of these concepts, I built a simple Counter component that uses the use_state hook to keep track of its count value. The component includes two buttons to increment and decrement the count.
class Counter(Component):
def __init__(self):
super().__init__()
def render(self):
# Use the custom use_state hook to manage count state
count, set_count = use_state(f"counter_{self.component_id}_count", 0)
def increment():
set_count(count + 1)
def decrement():
set_count(count - 1)
# Render the component using VNodes
return VNode(
type="container",
key=f"counter_container_{self.component_id}",
props={},
children=[
VNode(type="text", key=f"count_display_{self.component_id}", props={"content": f"Current Count: {count}"}),
VNode(type="button", key=f"increment_button_{self.component_id}", props={"label": "Increase", "on_click": increment}),
VNode(type="button", key=f"decrement_button_{self.component_id}", props={"label": "Decrease", "on_click": decrement})
]
)
5. Handling Re-Renders
One of the biggest challenges was managing re-renders. Streamlit’s top-down execution model means that every interaction re-runs the entire script. I had to ensure that each state change was immediately reflected in the UI by leveraging st.session_state effectively.
Challenges and Solutions
- State Persistence Across Reruns:
Initially, I encountered issues where state was reset after every interaction. To solve this, I used st.session_state to persist state values and ensure they were not lost during reruns.
- Delayed UI Updates:
Streamlit’s rerun mechanism caused a delay in UI updates. To overcome this, I avoided using unnecessary st.experimental_rerun() calls and relied on session_state to directly update UI components.
- Managing Component Identity:
Each component needed a unique identifier to keep track of its state. I used UUIDs to generate unique keys for each component instance, ensuring that their state remained independent.
Final Thoughts
Building this mini framework was a great way to understand the intricacies of both Streamlit and React. It gave me a deeper appreciation for how frontend frameworks manage state, handle re-renders, and provide a smooth developer experience.
While this is a basic implementation, it opens up possibilities for creating more complex component-based systems on top of Streamlit. I hope this encourages other developers to explore similar concepts and push the boundaries of what Streamlit can do.
What’s Next?
I plan to continue refining this framework and potentially add more React-like features such as context, props, and even lifecycle methods. If you’re interested, check out the GitHub repository for the code and feel free to contribute or share your thoughts!