Хотите создать полноценное веб-приложение без JavaScript, используя только Python? В этой статье мы с нуля создадим простой фитнес-трекер, используя исключительно Python и Reflex, и увидим, как легко можно объединить бэкенд и фронтенд логику, работать с базой данных и создавать реактивный пользовательский интерфейс.
Что мы создадим?
Наше приложение будет:
Вот как это будет выглядеть:
- отслеживать количество тренировок в неделю;
- показывать прогресс в виде интерактивного графика;
- позволять просматривать историю за предыдущие недели;
- Автоматически сохранять данные в SQLite.
Вот как это будет выглядеть:

Почему Reflex?
Reflex (ранее известный как Pynecone) это open-source фреймворк, который объединяет фронтенд и бэкенд инструментарий на Python. Если вы работали с React или Streamlit, многие концепции покажутся знакомыми, но здесь всё реализовано через декларативный подход. Главные плюсы:
- Чистый Python. Весь код – от логики до интерфейса – пишется на Python. И не нужно переключаться между языками.
- Реактивность. Изменения в состоянии приложения (бэкенде) автоматически отражаются в интерфейсе (фронтенде) без сложного управления DOM или ручного обновления.
- Быстрая разработка. Позволяет быстро прототипировать и создавать полнофункциональные приложения.
- Компонентный подход. UI строится из готовых компонентов, что упрощает разработку и поддержку.
Подготовка окружения и установка Reflex
Устанавливаем фреймворк: `pip install reflex`, создаём директорию для проекта, после чего инициализируем Relfex-проект: `reflex init`.
Reflex предложит выбрать шаблон:
Reflex предложит выбрать шаблон:
Get started with a template:
(0) blank (https://blank-template.reflex.run) - A blank Reflex app.
(1) ai - Generate a template using AI [Experimental]
(2) choose templates - Choose an existing template.
Which template would you like to use? (0): 0
Для нашего примера выберем опцию (0) blank, чтобы начать с чистого листа.
После инициализации структура вашего проекта будет выглядеть примерно так:

Reflex сгенерировал для нас базовый шаблон в файле, который соответствует названию проекта, в моем случае это `fitness_reflex/fitness_reflex.py`. Давайте взглянем на него (я немного упрощу для ясности):
"""Welcome to Reflex! This file outlines the steps to create a basic app."""
import reflex as rx
from rxconfig import config
class State(rx.State):
"""The app state."""
...
def index() -> rx.Component:
# Welcome Page (Index)
return rx.container(
rx.color_mode.button(position="top-right"),
rx.vstack(
rx.heading("Welcome to Reflex!", size="9"),
rx.text(
"Get started by editing ",
rx.code(f"{config.app_name}/{config.app_name}.py"),
size="5",
),
rx.link(
rx.button("Check out our docs!"),
href="https://reflex.dev/docs/getting-started/introduction/",
is_external=True,
),
spacing="5",
justify="center",
min_height="85vh",
),
rx.logo(),
)
app = rx.App()
app.add_page(index)
Ключевые моменты:
- Класс `State`: это сердце нашего приложения. Здесь хранятся данные (переменные состояния) и методы для их изменения (обработчики событий). Можно думать об этом как о бэкенде нашего UI. Важно: переменные в State должны быть базовых типов (`str`, `int`, `list`, `dict` и т.д.) или моделей данных Reflex.
- Функции, возвращающие `rx.Component`. Они определяют, как выглядит наш интерфейс. `index()` — это функция для главной страницы (/). Можно создавать и другие функции для разных страниц или переиспользуемых частей UI.
- `rx.App`: Экземпляр нашего приложения, к которому мы добавляем страницы (`add_page`).
Теперь настроим подключение к базе данных. Для простоты будем использовать SQLite. Откроем файл `rxconfig.py` и добавим URL базы данных:
# rxconfig.py
import reflex as rx
config = rx.Config(
app_name="fitness_reflex",
db_url="sqlite:///reflex.db", # <-- Добавляем эту строку
)
Убедимся, что все настроено правильно. Запустим сервер: `reflex run`.
После этого увидим что-то вроде:
После этого увидим что-то вроде:
─────────────────────────────────────────────────────────────────────── Starting Reflex App ───────────────────────────────────────────────────────────────────────
[13:05:26] Compiling: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 16/16 0:00:00
─────────────────────────────────────────────────────────────────────────── App Running ───────────────────────────────────────────────────────────────────────────
App running at: http://localhost:3000
Backend running at: http://0.0.0.0:8000
Открываем `http://localhost:3000` (или указанный адрес) в браузере. Если все ок, то мы должны увидеть стартовую страницу Reflex. Двигаемся дальше!
Модель данных для сохранения тренировок
Наше приложение будет хранить информацию о тренировках. Для этого создадим модель `Workout` с использованием `sqlmodel` — ORM, встроенного в Reflex. Нам нужно только одно поле — дата и время выполнения тренировки.
Добавляем следующий код в файл проекта:
Добавляем следующий код в файл проекта:
from datetime import datetime, timezone, timedelta
from sqlmodel import Field
import sqlalchemy
# Определяем модель данных для таблицы 'workout'
class Workout(rx.Model, table=True):
"""Модель для хранения записи о тренировке в БД."""
# Поле для хранения времени завершения тренировки
# Используем sqlalchemy.Column для установки значения по умолчанию на стороне БД
completed: datetime = Field(
sa_column=sqlalchemy.Column(
sqlalchemy.DateTime(timezone=True), # Тип данных в БД с таймзоной
server_default=sqlalchemy.func.now(), # Значение по умолчанию - текущее время БД
nullable=False # Поле не может быть пустым
)
)
# --- Остальной код (State, index, app) ниже ---
Здесь мы определили модель `Workout`, которая будет соответствовать таблице `workout` в базе данных. У нее всего одно поле `completed` типа `datetime`. Чтобы поле `completed` автоматически заполнялось текущим временем при создании записи в БД, мы используем `server_default=sqlalchemy.func.now()` внутри `sqlalchemy.Column`. Простое `default=datetime.now()` в `Field` установило бы время запуска приложения, а не время создания записи, что нам не подходит. Иногда приходится смешивать синтаксис SQLModel и SQLAlchemy для таких вещей.
Теперь нужно создать соответствующую таблицу в нашей SQLite базе данных. Reflex интегрирован с Alembic для управления миграциями БД. Выполним следующие команды в терминале :
# 1. Инициализация Alembic
reflex db init
# 2. Создание файла миграции на основе изменений в моделях
reflex db makemigrations --message "Добавляем модель Workout"
# 3. Применение миграции к базе данных
reflex db migrate
Теперь в нашей базе `reflex.db` появилась таблица `workout`.
Логика приложения
Теперь давайте добавим логику для загрузки тренировок из базы данных. Это будет происходить в классе `State` — месте, где Reflex хранит состояние приложения и методы для его изменения.
# fitness_tracker/fitness_tracker.py
# ... (импорты и модель Workout)
WEEKLY_GOAL = 5 # Наша цель - 5 тренировок в неделю
class State(rx.State):
"""Состояние приложения (бэкенд-логика и данные)."""
workouts: list[str] = [] # Список строк с датами тренировок для отображения
target: int = WEEKLY_GOAL # Еженедельная цель
current_week_offset: int = 0 # Смещение текущей недели (0 - текущая, -1 - прошлая, 1 - следующая)
# --- Методы для загрузки данных ---
def load_workouts_for_week(self, week_offset: int):
"""Загружает тренировки для недели со смещением week_offset."""
today = datetime.now(timezone.utc)
# Вычисляем начало и конец нужной недели
start_of_current_week = today - timedelta(days=today.weekday())
start_date = start_of_current_week + timedelta(weeks=week_offset)
# Устанавливаем время на начало дня
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = start_date + timedelta(days=7)
# Запрос к БД с использованием сессии Reflex
with rx.session() as session:
db_workouts = (
session.query(Workout)
.filter(
Workout.completed >= start_date,
Workout.completed < end_date,
)
.order_by(Workout.completed) # Сортируем для порядка
.all()
)
# Форматируем даты в строки для отображения (State не хранит сложные объекты)
self.workouts = [
w.completed.strftime("%Y-%m-%d %H:%M") for w in db_workouts
]
# --- Остальной код (index, app) ниже ---
В `State` мы создали: `workouts` (список строк дат), `target` (цель), `current_week_offset` (смещение недели). А метод `load_workouts_for_week` получает данные из БД с помощью `rx.session()` и фильтрует их по дате. Даты форматируются в строки перед сохранением в `self.workouts`, так как `State` работает с простыми типами.
Вычисляем прогресс с помощью свойств
Reflex позволяет создавать вычисляемые свойства с помощью декоратора `@rx.var`. Это переменные, которые автоматически обновляются при изменении данных. Добавим несколько таких свойств. `progress`, `progress_percentage`, `goal_reached`, `is_current_week_view`, `current_week_display` автоматически пересчитываются, когда изменяются данные, от которых они зависят (`workouts`, `target`, `current_week_offset`). Они идеальны для отображения динамической информации в UI.
# --- Вычисляемые свойства (Computed Properties) ---
# Эти свойства автоматически пересчитываются при изменении зависимых данных
@rx.var
def progress(self) -> int:
"""Количество выполненных тренировок на выбранной неделе."""
return len(self.workouts)
@rx.var
def progress_percentage(self) -> int:
"""Прогресс выполнения цели в процентах."""
if self.target == 0: # Избегаем деления на ноль
return 100 if self.progress > 0 else 0
return int(min(100, (self.progress / self.target) * 100)) # Ограничиваем 100%
@rx.var
def goal_reached(self) -> bool:
"""Достигнута ли еженедельная цель?"""
return self.progress >= self.target
@rx.var
def is_current_week_view(self) -> bool:
"""Просматривается ли текущая календарная неделя?"""
return self.current_week_offset == 0
@rx.var
def current_week_display(self) -> str:
"""Строка для отображения номера недели и года."""
# Рассчитываем дату внутри отображаемой недели
display_date = datetime.now(timezone.utc) + timedelta(weeks=self.current_week_offset)
# Используем isocalendar для получения номера недели по ISO
year, week, _ = display_date.isocalendar()
return f"{year} - Неделя {week:02}"
Эти свойства помогут нам отображать прогресс и управлять интерфейсом. Например, `progress_percentage` будет обновлять прогресс-бар, а `goal_reached` покажет, достигли ли мы цели.
Навигация по неделям
Чтобы пользователи могли листать недели, добавим методы для переключения:
def load_current_week_workouts(self):
"""Загружает тренировки для текущей отображаемой недели."""
self.load_workouts_for_week(self.current_week_offset)
# --- Обработчики событий (Event Handlers) ---
def show_previous_week(self):
"""Переключиться на предыдущую неделю."""
self.current_week_offset -= 1
self.load_current_week_workouts() # Перезагружаем данные
def show_next_week(self):
"""Переключиться на следующую неделю."""
# Ограничим переход в будущее, если не хотим разрешать просмотр будущих недель
# if self.current_week_offset < 0: # Пример ограничения
self.current_week_offset += 1
self.load_current_week_workouts() # Перезагружаем данные
Методы `show_previous_week`, `show_next_week` будут вызываться при нажатии на кнопки в интерфейсе. Они изменяют состояние `current_week_offset`), а затем вызывают `load_current_week_workouts`, чтобы обновить список тренировок.
Добавим возможность записывать новые тренировки:
def log_workout(self):
"""Записать новую тренировку в БД."""
# Проверяем, что мы на текущей неделе, чтобы не логировать в прошлом/будущем
if not self.is_current_week_view:
return # Ничего не делаем, если пытаемся логировать не на текущей неделе
with rx.session() as session:
new_workout = Workout() # Создаем экземпляр, 'completed' заполнится автоматически
session.add(new_workout)
session.commit() # Сохраняем в БД
session.refresh(new_workout) # Обновляем объект из БД (опционально)
# Перезагружаем данные, чтобы увидеть новую тренировку
self.load_current_week_workouts()
Этот метод создает новую запись в базе с текущей датой и обновляет список тренировок.
Интерфейс: собираем компоненты
Теперь, когда логика готова, соберем интерфейс. Reflex предоставляет компоненты, такие как `rx.vstack` (вертикальный стек), `rx.button` (кнопка) и `rx.progress` (прогресс-бар). Вместо того чтобы писать весь UI внутри функции `index`, разобьем его на небольшие, переиспользуемые функции-компоненты. Это улучшает читаемость кода.
Отображение прогресса:
def progress_display() -> rx.Component:
"""Отображает заголовок недели и прогресс-бар."""
return rx.vstack( # Вертикальный стек
rx.heading(State.current_week_display, size="5", margin_bottom="0.5em"), # Используем вычисляемое свойство
rx.progress(value=State.progress_percentage, width="100%", color_scheme="green", size="3"), # Используем вычисляемое свойство
rx.text(f"{State.progress} из {State.target} тренировок", size="2", margin_top="0.2em"), # Показываем числовой прогресс
align="center", # Центрируем элементы внутри vstack
width="80%", # Задаем ширину блока
margin_bottom="1.5em"
)
Кнопки навигации:
def week_navigation_buttons() -> rx.Component:
"""Кнопки для переключения недель."""
return rx.hstack( # Горизонтальный стек
rx.button("⬅️ Прошлая неделя", on_click=State.show_previous_week, size="2"), # Кнопка вызывает метод State
rx.spacer(), # Распорка для расталкивания кнопок
# Опционально: отключаем кнопку "Следующая", если мы на текущей неделе
rx.button(
"Следующая неделя ➡️",
on_click=State.show_next_week,
size="2",
disabled=State.is_current_week_view # Отключаем кнопку, если это текущая неделя
),
spacing="4", # Пространство между элементами
width="80%", # Задаем ширину блока
justify="center", # Центрируем содержимое
margin_bottom="1em"
)
Кнопка логирования с условием:
def conditional_workout_logging_section() -> rx.Component:
"""Отображает кнопку 'Записать' или сообщение о достижении цели."""
return rx.vstack( # Используем vstack для центрирования
# Условное отображение с помощью rx.cond
rx.cond(
State.goal_reached, # Если цель достигнута...
rx.text("🎉 Отлично! Цель на неделю выполнена! 💪", size="4", color_scheme="green", weight="bold"),
# Иначе (цель не достигнута)...
rx.cond(
State.is_current_week_view, # Проверяем, текущая ли это неделя...
rx.button( # Если да, показываем кнопку
"✅ Записать тренировку",
on_click=State.log_workout, # Кнопка вызывает метод State
size="3",
color_scheme="grass", # Зеленая кнопка
),
# Иначе (не текущая неделя)...
rx.text("") # Ничего не показываем
)
),
align="center",
width="80%",
margin_top="1.5em",
min_height="3em" # Резервируем место, чтобы UI не прыгал
)
Список тренировок:
def workout_list() -> rx.Component:
"""Отображает список выполненных тренировок."""
return rx.vstack(
rx.heading("История тренировок", size="6", margin_bottom="0.5em"),
# Отображение списка с помощью rx.foreach
rx.cond( # Показываем сообщение, если список пуст
State.workouts, # Условие: есть ли элементы в списке?
rx.vstack( # Если список не пуст, отображаем его
rx.foreach(
State.workouts, # Итерируемся по списку строк-дат
lambda workout_date: rx.box( # Оборачиваем каждую строку в Box для стилизации
rx.text(f"✅ {workout_date}"),
border="1px solid #e2e8f0",
padding="0.5em",
border_radius="md",
width="100%"
)
),
spacing="2", # Пространство между записями
align="stretch", # Растягиваем элементы на всю ширину
),
rx.text("На этой неделе тренировок еще не было.", color_scheme="gray") # Сообщение, если список пуст
),
align="center",
width="80%",
margin_top="1em",
min_height="10em" # Минимальная высота, чтобы избежать скачков интерфейса
)
Теперь объединим компоненты в главную страницу:
def index() -> rx.Component:
"""Главная страница приложения."""
return rx.container( # Используем контейнер для центрирования и ограничения ширины
rx.vstack(
rx.heading("🏃♂️ Фитнес-трекер", size="8", margin_bottom="1em"),
progress_display(), # Наш компонент прогресса
week_navigation_buttons(), # Наши кнопки навигации
workout_list(), # Наш список тренировок
conditional_workout_logging_section(), # Наша кнопка или сообщение о цели
# Общие стили для вертикального стека
spacing="5", # Расстояние между основными блоками
align="center", # Центрируем все блоки по горизонтали
padding_y="2em", # Вертикальные отступы сверху/снизу
),
max_width="600px", # Ограничиваем максимальную ширину контейнера
center_content=True, # Центрируем содержимое контейнера
min_height="100vh", # Минимальная высота на весь экран
)
# --- Конфигурация приложения ---
app = rx.App()
app.add_page(
index,
title="Фитнес-трекер | Reflex", # Заголовок вкладки браузера
# Загружаем данные при первой загрузке страницы
on_load=State.load_current_week_workouts
)
Что мы используем при создании этих функций?
- Компоненты Reflex: `rx.vstack`, `rx.hstack`, `rx.heading`, `rx.text`, `rx.progress`, `rx.button`, `rx.spacer`, `rx.box`, `rx.container`. Их имена говорят сами за себя.
- Привязка к State: мы обращаемся к переменным и вычисляемым свойствам `State` прямо в UI-функциях (например, `State.progress_percentage`, `State.current_week_display`).
- Обработчики событий: атрибут `on_click` у кнопок привязан к методам из `State` (например, `on_click=State.log_workout`). Reflex сам позаботится о вызове нужного метода на бэкенде и обновлении UI.
- Условное отображение: вместо `if`/`else` в Python коде для показа/скрытия элементов используется `rx.cond`.
- Отображение списков: для рендеринга списка элементов (наших тренировок) используется `rx.foreach`.
- Стилизация: Reflex позволяет добавлять CSS-свойства прямо как аргументы компонентам (`size`, `width`, `color_scheme`, `margin_bottom`, `padding`, `border_radius` и т.д.).
- Предзагрузка данных: в `app.add_page` мы добавили `on_load=State.load_current_week_workouts`. Это гарантирует, что данные для текущей недели будут загружены сразу при открытии приложения.
Результат: что получилось?
Запустите приложение еще раз (`reflex run`). Откройте его в браузере и увидите результат!
Это уже хорошая база, которую можно улучшать. Вот пара идей:
- Попробуйте нажать кнопку "Записать тренировку". Прогресс-бар должен обновиться, и в списке появится новая запись.
- Достигните цели (нажмите кнопку 5 раз). Кнопка должна исчезнуть, и появится поздравительное сообщение.
- Переключитесь на прошлую или следующую неделю. Список тренировок должен очиститься (если там еще ничего нет). Кнопка записи должна быть неактивна или отсутствовать на не-текущих неделях.
Это уже хорошая база, которую можно улучшать. Вот пара идей:
- Настраиваемая цель: можно добавить поле `rx.input` для установки своей еженедельной цели (`State.target`). Reflex автоматически обновит все зависимые части UI.
- Ручной ввод даты: позволить пользователю выбирать дату тренировки, чтобы можно было записывать пропущенные дни.
- Визуализация: добавить график (например, с помощью `rx.recharts`) для отображения статистики тренировок по неделям.
- Редактирование/Удаление: Добавить возможность удалять ошибочно добавленные тренировки.
- Более сложная модель: Учитывать тип тренировки, длительность, калории и т.д.
- Экспорт данных в CSV/Excel.
Итого: стоит ли пробовать Relfex?
Это хороший инструмент для питонистов создавать веб-приложения без лишней сложности. По сравнению с Streamlit, Reflex дает больше контроля над интерфейсом, а по сравнению с Django — меньше кода для простых задач.
Это хороший инструмент для питонистов создавать веб-приложения без лишней сложности. По сравнению с Streamlit, Reflex дает больше контроля над интерфейсом, а по сравнению с Django — меньше кода для простых задач.
Плюсы:
Минусы:
Для небольших и средних проектов, внутренних инструментов, дашбордов или быстрого прототипирования Reflex выглядит очень перспективно. Он точно выигрывает у Streamlit/Gradio в гибкости кастомизации UI и управлении состоянием, при этом оставаясь чисто питонячим. Конечно, для больших, высоконагруженных порталов с командой фронтендеров, классический стек (вроде Django/FastAPI + React/Vue/HTMX) пока остается стандартом.
Но для соло-разработчика или бэкендера, которому нужно "сделать просто и интерактивно", Reflex может быть отличным выбором.
- Чистый Python, низкий порог входа для Python-разработчиков.
- Быстрая разработка прототипов и несложных приложений.
- Отличная система состояния и реактивности.
- Хорошая интеграция с базами данных через SQLModel.
- Активно развивается.
Минусы:
- Молодой фреймворк (могут быть изменения в API, меньше готовых решений для сложных задач по сравнению с JS-экосистемой).
- Для очень сложных и кастомизированных интерфейсов может не хватить гибкости "из коробки" (хотя есть способы интеграции React-компонентов).
Для небольших и средних проектов, внутренних инструментов, дашбордов или быстрого прототипирования Reflex выглядит очень перспективно. Он точно выигрывает у Streamlit/Gradio в гибкости кастомизации UI и управлении состоянием, при этом оставаясь чисто питонячим. Конечно, для больших, высоконагруженных порталов с командой фронтендеров, классический стек (вроде Django/FastAPI + React/Vue/HTMX) пока остается стандартом.
Но для соло-разработчика или бэкендера, которому нужно "сделать просто и интерактивно", Reflex может быть отличным выбором.