Python Analytics Dashboard & Internal Tool Tutorial
Build a Python analytics dashboard with a live table, an add-data form, and a chart that updates as you add customers—all in Python. Use this pattern as the foundation for internal tools, admin panels, and CRM-style apps.
Before you start: No Reflex experience is required. Familiarity with Python helps; the Basics Guide is a useful introduction.
Key takeaways
- Build an interactive analytics dashboard using Python components for the table, form, and chart.
- Store changing data in state variables and update it through event handlers.
- Recalculate chart data when a user submits the form so the table and chart reflect the same records.
- Extend the example with database queries or API calls to build an internal business tool using your own data.
What you'll learn
This tutorial is divided into several sections:
- Setup: Get your machine ready.
- Overview: Components and props.
- Dynamic data with State: Render data that changes.
- Add data with a form: Forms + event handlers.
- Plot a graph: Reflex's graphing components.
- Customize + Full app: Customize and see the finished code.
What are you building?
An interactive data dashboard: a table of users, a form to add more, and a bar chart that updates as data changes. Want to skip ahead? Jump to the Full app at the bottom.
CUSTOMER OVERVIEW
Your customers, at a glance
Manage your directory and see how it grows.
Customer directory
2 totalCustomer breakdown
Number of customers by gender
Setup
- Install Reflex if you haven't already.
- Create a folder called
dashboard_tutorialandcdinto it. - Run
uv initanduv add reflex. - Run
uv run reflex initand choose template0(the blank template). - Run
uv run reflex runto start the app and confirm everything works.
Overview
Starter code
The reflex init command scaffolds an rxconfig.py (app config), an assets/ folder for static files, and a dashboard_tutorial/dashboard_tutorial.py module containing your app. Open that module and replace its contents — we'll build the app up from scratch.
A minimal Reflex page is just a component function plus an app that registers it:
import reflex as rx
def index() -> rx.Component:
return rx.text("Hello World!")
app = rx.App()
app.add_page(index)User dataclass as you work through the steps. Replace the earlier State, index, or component definitions when a new version is shown, rather than adding a second definition. Keep app = rx.App() and app.add_page(index) at the bottom of the file, after all definitions. We will update registration in Customize.Create a table
The rx.table component has a root that wraps a header and a body. The header takes row → column_header_cell components; the body takes row → cell components holding the actual data. Props like variant and size customize the look:
def index() -> rx.Component:
return rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Gender"),
),
),
rx.table.body(
rx.table.row(
rx.table.cell("Danilo Sousa"),
rx.table.cell("[email protected]"),
rx.table.cell("Male"),
),
rx.table.row(
rx.table.cell("Zahra Ambessa"),
rx.table.cell("[email protected]"),
rx.table.cell("Female"),
),
),
variant="surface",
size="3",
)ExpandCollapse
Dynamic data with State
The table above is static — the rows are hardcoded. To make it dynamic, we move the data onto state: a Python class whose fields (state vars) hold the app's data and whose methods (event handlers) mutate them.
We'll model each row as a User dataclass so we can access fields by name (user.name) instead of by index:
import dataclasses
@dataclasses.dataclass
class User:
name: str
email: str
gender: str
class State(rx.State):
users: list[User] = [
User(name="Danilo Sousa", email="[email protected]", gender="Male"),
User(name="Zahra Ambessa", email="[email protected]", gender="Female"),
]From example data to your own data. This tutorial keeps two users in memory. For a business dashboard, load records from PostgreSQL or MySQL using a Python database library, or fetch data from Airtable or a CRM through its API. Map the records to the fields in User and update State.users. Saving form submissions requires a corresponding database write or API call. Start with the database overview.
To iterate a list state var, use rx.foreach (see rendering iterables) — it takes an iterable and a function that renders each item. Here show_user receives a User and returns a table.row:
def show_user(user: User) -> rx.Component:
return rx.table.row(
rx.table.cell(user.name),
rx.table.cell(user.email),
rx.table.cell(user.gender),
)
def index() -> rx.Component:
return rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Gender"),
),
),
rx.table.body(
rx.foreach(State.users, show_user),
),
variant="surface",
size="3",
)ExpandCollapse
The table looks the same, but the rows now come from state — next we'll add a form that appends to State.users so new rows appear automatically.
Add data with a form
We build a form using rx.form, which takes several components such as rx.input and rx.select, which represent the form fields that allow you to add information to submit with the form. Check out the form docs for more information on form components.
The rx.input component takes in several props. The placeholder prop is the text that is displayed in the input field when it is empty. The name prop is the name of the input field, which gets passed through in the dictionary when the form is submitted. The required prop is a boolean that determines if the input field is required.
The rx.select component takes in a list of options that are displayed in the dropdown. The name prop identifies the submitted field. default_value="Male" selects an initial value; a placeholder alone does not select an option.
The unstyled form above has no space between its fields. Wrap them in rx.vstack to add consistent gaps, use align="stretch" to give them matching widths, and limit the form to 24em so it stays readable on wide screens. See the layout docs for more options.
Now you have probably realised that we have all the form fields, but we have no way to submit the form. We can add a submit button to the form by adding a rx.button component to the vstack component. The rx.button component takes in the text that is displayed on the button and the type prop which is the type of button. The type prop is set to submit so that the form is submitted when the button is clicked.
In addition to this we need a way to update the users state variable when the form is submitted. All state changes are handled through functions in the state class, called event handlers.
Components have special props called event triggers, such as on_submit, that can be used to make components interactive. Event triggers connect components to event handlers, which update the state. Different event triggers expect the event handler that you hook them up to, to take in different arguments (and some do not take in any arguments).
The on_submit event trigger of rx.form is hooked up to the add_user event handler that is defined in the State class. This event trigger expects to pass a dict, containing the form data, to the event handler that it is hooked up to. The add_user event handler takes in the form data as a dictionary and appends it to the users state variable.
class State(rx.State):
...
def add_user(self, form_data: dict):
self.users.append(User(**form_data))
def form():
return rx.form(
rx.vstack(
rx.input(placeholder="User Name", name="name", required=True),
rx.input(
placeholder="[email protected]",
name="email",
),
rx.select(
["Male", "Female"],
placeholder="Select gender",
default_value="Male",
name="gender",
custom_attrs={"aria-label": "Gender"},
),
rx.button("Submit", type="submit"),
align="stretch",
spacing="3",
width="100%",
),
on_submit=State.add_user,
reset_on_submit=True,
width="100%",
max_width="24em",
)ExpandCollapse
Finally we must add the new form() component we have defined to the index() function so that the form is rendered on the page.
Below is the full code for the app so far. If you try this form out you will see that you can add new users to the table by filling out the form and clicking the submit button. The embedded demo also shows a toast; the code below focuses on adding the row.
class State(rx.State):
users: list[User] = [
User(name="Danilo Sousa", email="[email protected]", gender="Male"),
User(name="Zahra Ambessa", email="[email protected]", gender="Female"),
]
def add_user(self, form_data: dict):
self.users.append(User(**form_data))
def show_user(user: User):
"""Show a person in a table row."""
return rx.table.row(
rx.table.cell(user.name),
rx.table.cell(user.email),
rx.table.cell(user.gender),
)
def form():
return rx.form(
rx.vstack(
rx.input(placeholder="User Name", name="name", required=True),
rx.input(
placeholder="[email protected]",
name="email",
),
rx.select(
["Male", "Female"],
placeholder="Select gender",
default_value="Male",
name="gender",
custom_attrs={"aria-label": "Gender"},
),
rx.button("Submit", type="submit"),
align="stretch",
spacing="3",
width="100%",
),
on_submit=State.add_user,
reset_on_submit=True,
width="100%",
max_width="24em",
)
def index() -> rx.Component:
return rx.vstack(
form(),
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Gender"),
),
),
rx.table.body(
rx.foreach(State.users, show_user),
),
variant="surface",
size="3",
),
)ExpandCollapse
Put the form in a dialog
In Reflex, we like to make the user interaction as intuitive as possible. Placing the form we just constructed in an overlay creates a focused interaction by dimming the background, and ensures a cleaner layout when you have multiple action points such as editing and deleting as well.
We will place the form inside of a rx.dialog component (also called a modal). The rx.dialog.root contains all the parts of a dialog, and the rx.dialog.trigger wraps the control that will open the dialog. In our case the trigger will be an rx.button that says "Add User" as shown below.
rx.dialog.trigger(
rx.button(
rx.icon("plus", size=18),
rx.text("Add User", size="2"),
),
)After the trigger we have the rx.dialog.content which contains everything within our dialog, including a title, a description and our form. The Cancel button uses rx.dialog.close. The Submit button stays inside the form, and the on_submit handler closes the controlled dialog only after the required fields validate.
rx.flex(
rx.dialog.close(
rx.button("Cancel", type="button", variant="soft", color_scheme="gray"),
),
rx.button("Submit", type="submit"),
spacing="3",
justify="end",
)To close the dialog after a valid submission, add add_user_dialog_open and its setter to your existing State class, then close it in add_user as shown in the full code below. The isolated embedded demo uses DialogState3 for the same behavior. The browser will not call add_user while a required field is empty, so the dialog remains open for native validation in that case.
The demo below shows the dialog layout. For your app, use the complete State and add_customer_button code immediately after the demo.
At this point we have an app that allows you to add users to a table by filling out a form. The form is placed in a dialog that can be opened by clicking the "Add User" button. We change the name of the component from form to add_customer_button and update this in our index component. The full app so far and code are below.
@dataclasses.dataclass
class User:
"""The user model."""
name: str
email: str
gender: str
class State(rx.State):
users: list[User] = [
User(name="Danilo Sousa", email="[email protected]", gender="Male"),
User(name="Zahra Ambessa", email="[email protected]", gender="Female"),
]
add_user_dialog_open: bool = False
def set_add_user_dialog_open(self, open_: bool):
"""Set whether the add-user dialog is open."""
self.add_user_dialog_open = open_
def add_user(self, form_data: dict):
self.users.append(User(**form_data))
self.add_user_dialog_open = False
def show_user(user: User):
"""Show a person in a table row."""
return rx.table.row(
rx.table.cell(user.name),
rx.table.cell(user.email),
rx.table.cell(user.gender),
)
def add_customer_button() -> rx.Component:
return rx.dialog.root(
rx.dialog.trigger(
rx.button(
rx.icon("plus", size=18),
rx.text("Add User", size="2"),
),
),
rx.dialog.content(
rx.dialog.title(
"Add New User",
),
rx.dialog.description(
"Fill the form with the user's info",
),
rx.form(
rx.flex(
rx.input(placeholder="User Name", name="name", required=True),
rx.input(
placeholder="[email protected]",
name="email",
),
rx.select(
["Male", "Female"],
placeholder="Select gender",
default_value="Male",
name="gender",
custom_attrs={"aria-label": "Gender"},
),
rx.flex(
rx.dialog.close(
rx.button(
"Cancel",
type="button",
variant="soft",
color_scheme="gray",
),
),
rx.button("Submit", type="submit"),
spacing="3",
justify="end",
),
direction="column",
spacing="4",
),
on_submit=State.add_user,
reset_on_submit=False,
),
max_width="450px",
),
open=State.add_user_dialog_open,
on_open_change=State.set_add_user_dialog_open,
)
def index() -> rx.Component:
return rx.vstack(
add_customer_button(),
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Gender"),
),
),
rx.table.body(
rx.foreach(State.users, show_user),
),
variant="surface",
size="3",
),
)ExpandCollapse
Plot a graph
Next we'll plot the user data in a graph using Reflex's built-in recharts library, counting users by gender.
Real-time dashboards and cross-filtering. The chart in this tutorial updates after a form submission changes its state data. To extend it to streaming data, add a data source and an event handler that updates state as records arrive; see background events. For cross-filtering across several charts, connect chart click events to a shared filter in state and use that filter when computing each chart's data. These are extensions to the example below.
Transform the data
The graphing components in Reflex expect to take in a list of dictionaries. Each dictionary represents a data point on the graph and contains the x and y values. We will create a new event handler in the state called transform_data to transform the user data into the format that the graphing components expect. We must also create a new state variable called users_for_graph to store the transformed data, which will be used to render the graph.
from collections import Counter
class State(rx.State):
users: list[User] = []
users_for_graph: list[dict] = []
def add_user(self, form_data: dict):
self.users.append(User(**form_data))
self.transform_data()
def transform_data(self):
"""Transform user gender group data into a format suitable for visualization in graphs."""
# Count users of each gender group
gender_counts = Counter(user.gender for user in self.users)
# Transform into list of dict so it can be used in the graph
self.users_for_graph = [
{"name": gender_group, "value": count}
for gender_group, count in gender_counts.items()
]ExpandCollapse
As we can see above the transform_data event handler uses the Counter class from the collections module to count the number of users of each gender. We then create a list of dictionaries from this which we set to the state var users_for_graph.
Finally we can see that whenever we add a new user through submitting the form and running the add_user event handler, we call the transform_data event handler to update the users_for_graph state variable.
Render the graph
We use the rx.recharts.bar_chart component to render the graph. We pass through the state variable for our graphing data as data=State.users_for_graph. We also pass in a rx.recharts.bar component which represents the bars on the graph. The rx.recharts.bar component takes in the data_key prop which is the key in the data dictionary that represents the y value of the bar. The stroke and fill props are used to set the color of the bars.
The rx.recharts.bar_chart component also takes in rx.recharts.x_axis and rx.recharts.y_axis components which represent the x and y axes of the graph. The data_key prop of the rx.recharts.x_axis component is set to the key in the data dictionary that represents the x value of the bar. Finally we add width and height props to set the size of the graph.
def graph():
return rx.recharts.bar_chart(
rx.recharts.bar(
data_key="value",
stroke=rx.color("accent", 9),
fill=rx.color("accent", 8),
),
rx.recharts.x_axis(data_key="name"),
rx.recharts.y_axis(),
data=State.users_for_graph,
width="100%",
height=250,
)Finally we add this graph() component to our index() component so that the graph is rendered on the page. The code for the full app with the graph included is below. If you try this out you will see that the graph updates whenever you add a new user to the table.
from collections import Counter
class State(rx.State):
users: list[User] = [
User(name="Danilo Sousa", email="[email protected]", gender="Male"),
User(name="Zahra Ambessa", email="[email protected]", gender="Female"),
]
users_for_graph: list[dict] = []
add_user_dialog_open: bool = False
def set_add_user_dialog_open(self, open_: bool):
"""Set whether the add-user dialog is open."""
self.add_user_dialog_open = open_
def add_user(self, form_data: dict):
self.users.append(User(**form_data))
self.transform_data()
self.add_user_dialog_open = False
def transform_data(self):
"""Transform user gender group data into a format suitable for visualization in graphs."""
# Count users of each gender group
gender_counts = Counter(user.gender for user in self.users)
# Transform into list of dict so it can be used in the graph
self.users_for_graph = [
{"name": gender_group, "value": count}
for gender_group, count in gender_counts.items()
]
def show_user(user: User):
"""Show a person in a table row."""
return rx.table.row(
rx.table.cell(user.name),
rx.table.cell(user.email),
rx.table.cell(user.gender),
)
def add_customer_button() -> rx.Component:
return rx.dialog.root(
rx.dialog.trigger(
rx.button(
rx.icon("plus", size=18),
rx.text("Add User", size="2"),
),
),
rx.dialog.content(
rx.dialog.title(
"Add New User",
),
rx.dialog.description(
"Fill the form with the user's info",
),
rx.form(
rx.flex(
rx.input(placeholder="User Name", name="name", required=True),
rx.input(
placeholder="[email protected]",
name="email",
),
rx.select(
["Male", "Female"],
placeholder="Select gender",
default_value="Male",
name="gender",
custom_attrs={"aria-label": "Gender"},
),
rx.flex(
rx.dialog.close(
rx.button(
"Cancel",
type="button",
variant="soft",
color_scheme="gray",
),
),
rx.button("Submit", type="submit"),
spacing="3",
justify="end",
),
direction="column",
spacing="4",
),
on_submit=State.add_user,
reset_on_submit=False,
),
max_width="450px",
),
open=State.add_user_dialog_open,
on_open_change=State.set_add_user_dialog_open,
)
def graph():
return rx.recharts.bar_chart(
rx.recharts.bar(
data_key="value",
stroke=rx.color("accent", 9),
fill=rx.color("accent", 8),
),
rx.recharts.x_axis(data_key="name"),
rx.recharts.y_axis(),
data=State.users_for_graph,
width="100%",
height=250,
)
def index() -> rx.Component:
return rx.vstack(
add_customer_button(),
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Gender"),
),
),
rx.table.body(
rx.foreach(State.users, show_user),
),
variant="surface",
size="3",
),
graph(),
)ExpandCollapse
Even with the two seed users, the graph is empty until you add one — transform_data only runs when a user is added. The next section fixes that by calling it on page load.
Customize
Revisit app.add_page
At the beginning of this tutorial we mentioned that the app.add_page function is required for every Reflex app. This function is used to add a component to a page.
The app.add_page currently looks like this app.add_page(index). We could change the route that the page renders on by setting the route prop such as route="/custom-route", this would change the route to http://localhost:3000/custom-route for this page.
We can also set a title to be shown in the browser tab and a description as shown in search results.
To solve the problem we had above about our graph not loading when the page loads, we can use on_load inside of app.add_page to call the transform_data event handler when the page loads. This would look like on_load=State.transform_data. Below see what our app.add_page would look like with some of the changes above added.
app.add_page(
index,
title="Customer Data App",
description="A simple app to manage customer data.",
on_load=State.transform_data,
)Customize the theme
Configure Radix styling in rxconfig.py with RadixThemesPlugin. Keep the generated app_name and any other configuration you already use:
# rxconfig.py
import reflex as rx
config = rx.Config(
app_name="dashboard_tutorial",
plugins=[
rx.plugins.RadixThemesPlugin(
theme=rx.theme(radius="full", accent_color="grass"),
),
],
)radius sets the default corner radius and accent_color sets the theme's accent color. Individual components can override these defaults. See theming for more options. Restart the app after editing rxconfig.py.
Full app styled
Replace dashboard_tutorial/dashboard_tutorial.py with the complete code below. Keep the rxconfig.py from the previous section. The app uses session state: added users are not saved to a database.
Finally let's make some styling updates. We will add hover styling to the table rows and vertically align the cells inside show_user with style={"_hover": {"bg": rx.color("gray", 3)}}, align="center".
The finished layout uses one clean card with a directory and an audience breakdown. A live badge shows the customer count. The chart uses horizontal bars with a numeric x-axis and category y-axis; its range adjusts to the data. The table scrolls horizontally on smaller screens.
Check out the full code and interactive app below:
Customers
2Your customer directory and audience breakdown.
Audience breakdown
Customers by gender
import dataclasses
from collections import Counter
import reflex as rx
@dataclasses.dataclass
class User:
"""The user model."""
name: str
email: str
gender: str
class State(rx.State):
users: list[User] = [
User(name="Danilo Sousa", email="[email protected]", gender="Male"),
User(name="Zahra Ambessa", email="[email protected]", gender="Female"),
]
users_for_graph: list[dict] = []
add_user_dialog_open: bool = False
def set_add_user_dialog_open(self, open_: bool):
"""Set whether the add-user dialog is open."""
self.add_user_dialog_open = open_
def add_user(self, form_data: dict):
self.users.append(User(**form_data))
self.transform_data()
self.add_user_dialog_open = False
def transform_data(self):
"""Transform user gender group data into a format suitable for visualization in graphs."""
# Count users of each gender group
gender_counts = Counter(user.gender for user in self.users)
# Transform into list of dict so it can be used in the graph
self.users_for_graph = [
{"name": gender_group, "value": count}
for gender_group, count in gender_counts.items()
]
def show_user(user: User):
"""Show a user in a table row."""
return rx.table.row(
rx.table.cell(user.name),
rx.table.cell(user.email),
rx.table.cell(user.gender),
style={"_hover": {"bg": rx.color("gray", 3)}},
align="center",
)
def add_customer_button() -> rx.Component:
return rx.dialog.root(
rx.dialog.trigger(
rx.button(
rx.icon("plus", size=18),
rx.text("Add User", size="2"),
),
),
rx.dialog.content(
rx.dialog.title(
"Add New User",
),
rx.dialog.description(
"Fill the form with the user's info",
),
rx.form(
rx.flex(
rx.input(placeholder="User Name", name="name", required=True),
rx.input(
placeholder="[email protected]",
name="email",
),
rx.select(
["Male", "Female"],
placeholder="Select gender",
default_value="Male",
name="gender",
custom_attrs={"aria-label": "Gender"},
),
rx.flex(
rx.dialog.close(
rx.button(
"Cancel",
type="button",
variant="soft",
color_scheme="gray",
),
),
rx.button("Submit", type="submit"),
spacing="3",
justify="end",
),
direction="column",
spacing="4",
),
on_submit=State.add_user,
reset_on_submit=False,
),
max_width="450px",
),
open=State.add_user_dialog_open,
on_open_change=State.set_add_user_dialog_open,
)
def graph():
return rx.recharts.bar_chart(
rx.recharts.cartesian_grid(
horizontal=False, stroke_dasharray="3 3", stroke=rx.color("slate", 4)
),
rx.recharts.x_axis(
type_="number",
allow_decimals=False,
axis_line=False,
tick_line=False,
domain=[0, "dataMax + 1"],
),
rx.recharts.y_axis(
type_="category",
data_key="name",
axis_line=False,
tick_line=False,
width=64,
),
rx.recharts.bar(
data_key="value",
fill=rx.color("accent", 9),
radius=[0, 6, 6, 0],
bar_size=28,
),
data=State.users_for_graph,
layout="vertical",
width="100%",
height=160,
margin={"top": 0, "right": 16, "bottom": 0, "left": 0},
)
def index() -> rx.Component:
return rx.box(
rx.box(
rx.box(
rx.box(
rx.heading("Customers", size="6", as_="h3", margin="0"),
rx.badge(
State.users.length(),
variant="soft",
color_scheme="gray",
radius="full",
),
display="flex",
align_items="center",
gap="12px",
),
rx.text(
"Your customer directory and audience breakdown.",
size="2",
color=rx.color("slate", 11),
margin_top="6px",
),
),
add_customer_button(),
display="flex",
align_items="center",
justify_content="space-between",
flex_wrap="wrap",
gap="16px",
padding="24px",
),
rx.box(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Gender"),
)
),
rx.table.body(rx.foreach(State.users, show_user)),
variant="ghost",
size="3",
width="100%",
),
overflow_x="auto",
width="100%",
border_top=f"1px solid {rx.color('slate', 4)}",
border_bottom=f"1px solid {rx.color('slate', 4)}",
padding_x="12px",
),
rx.box(
rx.text("Audience breakdown", size="3", weight="bold"),
rx.text(
"Customers by gender",
size="2",
color=rx.color("slate", 11),
margin_top="4px",
margin_bottom="20px",
),
graph(),
padding="24px",
),
width="100%",
min_width="0",
max_width="64em",
margin_x="auto",
margin_y="24px",
border=f"1px solid {rx.color('slate', 5)}",
border_radius="16px",
background=rx.color("slate", 1),
overflow="hidden",
box_shadow="0 4px 24px rgba(0, 0, 0, 0.03)",
style={
"& *": {"box_sizing": "border-box"},
"& p, & h3": {"text_align": "left"},
},
)
app = rx.App()
app.add_page(
index,
title="Customer Data App",
description="A simple app to manage customer data.",
on_load=State.transform_data,
)ExpandCollapse
Recap
You built:
- A table that displays user data.
- A form (inside a dialog) to add new users.
- A bar chart that visualizes the distribution.
Along the way you learned:
- State — how to store data that changes over time.
- Events — how to respond to user actions and update the UI.
- Styling — tweaking theme, layout, and hover states.
Use this table, form, and chart pattern as the starting point for an internal admin panel, an order tracker, or a sales dashboard. To replace a shared spreadsheet or connect a CRM, add persistent storage and the access controls your team needs.
FAQ
Which Python framework can I use for an internal tool or analytics dashboard?
Reflex lets you build the UI and backend logic in Python. This tutorial combines a data table, a form, and a bar chart into an interactive analytics dashboard. You can reuse those components for an internal tool without writing a separate JavaScript frontend.
Can this dashboard connect to a real database instead of hardcoded data?
Yes. Load records with a Python database library and assign them to State.users, then persist new records in the form's event handler. PostgreSQL and MySQL require a database connection; Airtable and CRM services require their own API integration. The database docs explain how to connect and query a database.
Does the chart update in real time as data changes?
The chart updates when transform_data changes users_for_graph, which happens after a user submits the form in this tutorial. External database changes do not automatically update this state. A streaming dashboard needs additional polling or event handling to fetch new data and refresh the chart.