Upgrading to Reflex 0.11
This page covers the 0.11 changes most likely to break an existing app, or to be missed in the changelog, with the fix for each. Several of the replacements, such as config.frontend_url, SetVar.to_array() and rx.client_state, do not exist on 0.10.x, so change that code in the same commit that upgrades Reflex.
Decorators on event handlers
@rx.event now returns the EventHandler itself instead of the function it decorates (#7673). A decorator stacked above @rx.event therefore receives the handler rather than the method. Such a method still runs and registers under its own name until 1.0, but 0.11 logs a deprecation warning:
import functools
import reflex as rx
def logged(fn):
@functools.wraps(fn)
def wrapper(self, *args, **kwargs):
print(f"calling {fn.__name__}")
return fn(self, *args, **kwargs)
return wrapper
class State(rx.State):
count: int = 0
@logged # Deprecated: `@logged` is applied above `@rx.event`.
@rx.event
def increment(self):
self.count += 1ExpandCollapse
Apply other decorators below @rx.event, which works on 0.10 too:
class State(rx.State):
count: int = 0
@rx.event
@logged
def increment(self):
self.count += 1The same change affects @rx.event outside a state:
- On a method of a class that is not a state, or on a
staticmethodorclassmethod,@rx.eventleaves the method unchanged and warns. Remove the decorator. Calling such a method through its class, as inHelper.compute(obj, 3), is not supported. - On a module-level function whose first parameter is not annotated with a state class, it leaves a plain function and warns. Remove the decorator from helper functions. A decentralized event handler, such as
def reset(state: MyState), is unchanged. - Applying it to a handler that is already bound to a state, as in
ping = rx.event(OtherState.ping), raisesTypeError. Decorate the function instead. - Stacking
@rx.eventtwice applies both, and the outer decorator's options win. - Code that read attributes of the decorated function, such as markers set by your own decorators, finds the function at the handler's
.fn.
Values returned by event handlers
On 0.10 a handler could return only None, an event or a list of events, and any other value raised TypeError. In 0.11 a handler may return any value (#7673, #7782, #7821):
- Backend code that awaits the handler, such as
total = await type(self).add(5)in another handler, receives the value. See Awaiting Event Handlers. - When an event trigger such as
on_clickruns the handler, a value that is not an event is dropped, and in dev mode a warning names the handler once. Such values are not sent to the frontend. Returned events still chain: a returnedrx.redirect("/")still runs in the browser.
A return value that can only be a mistaken event still raises TypeError, naming the handler: a bound handler such as self.target instead of type(self).target, an uncalled event creator such as rx.redirect, a coroutine, or a list that mixes events with other values. Yielded values must still be events.
class State(rx.State):
total: int = 0
@rx.event
def target(self): ...
@rx.event
def add(self, amount: int) -> int:
self.total += amount
return (
self.total
) # 0.10 raised TypeError; 0.11 returns it to an awaiting caller.
@rx.event
def go_home(self):
return (
rx.redirect
) # Raises TypeError: "Did you mean to call it: rx.redirect(...)?"
@rx.event
def chain(self):
return self.target # Raises TypeError.ExpandCollapse
Call the event creator, and reference other handlers through the class:
class State(rx.State):
@rx.event
def target(self): ...
@rx.event
def go_home(self):
return rx.redirect("/")
@rx.event
def chain(self):
return type(self).targetself.handler() still calls the handler's function directly, as on 0.10.
Origin checks in production
In prod mode the backend now accepts browser requests only from its own origin and from deploy_url, which defaults to None instead of http://localhost:3000 (#7641, #7799). On 0.10 any origin was allowed. With deploy_url unset, the backend sends no CORS headers, so browsers cannot read cross-origin responses, and the event websocket accepts a connection only when its Origin matches the request's Host. With deploy_url set, that origin is accepted as well. Dev mode still allows any origin.
For a split frontend and backend:
REFLEX_DEPLOY_URL=https://app.example.com reflex run --env prod --backend-only
REFLEX_API_URL=https://api.example.com reflex run --env prod --frontend-onlyX-Forwarded-Host and X-Forwarded-Proto count only when trusted_proxy_hops is set, and a trusted X-Forwarded-Proto: https also rejects pages served over plain http. The first websocket each worker rejects logs a warning that names the rejected origin and the setting to change. To restore the 0.10 behavior, set cors_allowed_origins=["*"]. See CORS and Self Hosting for the details.
Reading the deploy URL in app code
Because deploy_url now defaults to None, code that builds URLs from config.deploy_url produces text such as None/reset when it is unset. Use config.frontend_url for the frontend's origin: it is deploy_url when set, and http://localhost:<frontend_port> otherwise. When the backend serves the frontend from its own origin, as in single-port --env prod, use config.api_url for the app's public origin.
import reflex as rx
config = rx.config.get_config()
# 0.10: "http://localhost:3000/reset" when deploy_url was unset.
# 0.11: "None/reset".
reset_link = f"{config.deploy_url}/reset"reset_link = f"{config.frontend_url}/reset"Set and frozenset vars
A set or frozenset state var is now a SetVar instead of an ArrayVar (#7642). Sets have no order, so the list operations on it are deprecated: indexing, slicing, map, filter, join, reverse, +, *, the comparisons <, <=, > and >=, and contains with a field. They still compile with a deprecation warning until 1.0. Call .to_array() first:
class State(rx.State):
tags: set[str] = {"python", "reflex"}
rx.text(State.tags.join(", ")) # Deprecated: a set is not a sequence.
rx.text(State.tags.to_array().join(", "))rx.foreach accepts a set var directly. SetVar adds set operations such as union, intersection and issubset; see Set Operations.
Browser storage vars with a default factory
Declare an rx.Cookie, rx.LocalStorage or rx.SessionStorage var with a storage value as its default, or with a storage type annotation (#7582). On 0.10 a default_factory on such a var was ignored, or turned the var into an ordinary state var. In 0.11 the app fails to compile with VarTypeError: Browser storage var 'State.theme' cannot use a default_factory:
class State(rx.State):
theme: str = rx.field(
default_factory=lambda: rx.LocalStorage("light", name="theme")
)The storage key and options must be known when the app compiles, so give the storage value as the default:
class State(rx.State):
theme: str = rx.LocalStorage("light", name="theme")See Browser Storage.
Properties of Pydantic models and dataclasses
Reading a plain @property of a Pydantic model or a dataclass through a state var now raises VarAttributeError when the page compiles (#7648). The property was never sent to the frontend, so on 0.10 the var always rendered empty:
import pydantic
class User(pydantic.BaseModel):
first: str = ""
last: str = ""
@property
def full_name(self) -> str:
return f"{self.first} {self.last}"
class State(rx.State):
user: User = User()
rx.text(State.user.full_name) # Raises VarAttributeError.For a Pydantic model, declare the property with @pydantic.computed_field, which includes it in the serialized state:
class User(pydantic.BaseModel):
first: str = ""
last: str = ""
@pydantic.computed_field
@property
def full_name(self) -> str:
return f"{self.first} {self.last}"For a dataclass, register an rx.serializer that includes the value:
import dataclasses
@dataclasses.dataclass
class Point:
x: int = 0
@property
def double(self) -> int:
return self.x * 2
@rx.serializer
def serialize_point(point: Point) -> dict:
return {"x": point.x, "double": point.double}Printing a backend var on a state class
A backend var read on the state class, such as State._secret, is a Field since 0.10. In 0.11, print(), logging and str() of it raise BackendVarFormatError, as f-strings already did (#7651). The same error replaces the TypeError or ValueError raised when the var is passed where a frontend value is needed, such as a component prop, rx.toast(State._secret), or the title, description or image of app.add_page(...).
class State(rx.State):
_secret: str = ""
print(State._secret) # Raises BackendVarFormatError.
print(repr(State._secret)) # Field(default='', ...)
print(State._secret.default_value()) # The default value.Use repr() to print or log the field in server code. Reading the var on a state instance, such as self._secret, is unchanged. Upgrading to Reflex 0.10 lists the replacements for a backend var used in the UI.
Client state
rx.client_state replaces rx._x.client_state (#6936, #7804). The rx._x version keeps its argument order and warns until 1.0. In the new API, naming a var makes it global, and an unnamed var belongs to the outermost page or @rx.memo that uses it, so global_ref is gone:
A legacy var created without a name was still global. If other pages or the backend read it, give its replacement a name.
query = rx.client_state("", name="query")
rx.input(value=query.value, on_change=query.set)A name must be an ASCII identifier, a letter or underscore followed by letters, digits or underscores, without $. This now applies to rx._x.client_state too, which raises ValueError for a name such as "my-query". Declaring one name with two different defaults fails the compile with an error naming both declarations. See Naming And Scoping Client State.
Removed admin dashboard
The built-in starlette-admin dashboard is removed (#7739). rx.AdminDash(...) and rx.App(admin_dash=...) raise AdminDashRemovedError:
app = rx.App(admin_dash=rx.AdminDash(models=[User]))Install starlette-admin and mount it yourself with api_transformer. Always give it an AuthProvider: without one, the dashboard serves read and write access to every table. The admin dashboard guide has the full example, including the provider and a table mapping each rx.AdminDash argument:
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.sessions import SessionMiddleware
from starlette_admin.contrib.sqla import Admin, ModelView
from reflex.model import get_engine
# MyAuthProvider and secret_key as defined in the admin dashboard guide.
admin_app = Starlette()
admin = Admin(
get_engine(),
auth_provider=MyAuthProvider(),
secret_key=secret_key,
middlewares=[Middleware(SessionMiddleware, secret_key=secret_key)],
)
admin.add_view(ModelView(User))
admin.mount_to(admin_app)
app = rx.App(api_transformer=admin_app)The ASGI app and API transformers
Calling an rx.App instance, app(), returns a plain ASGI callable instead of a Starlette app (#7624). The result has no add_middleware, routes or mount:
asgi = app()
asgi.add_middleware(MyMiddleware) # AttributeError on 0.11.Add Starlette middleware and routes to an app that you pass as api_transformer. Reflex mounts its own routes inside it, so its middleware covers them too:
from starlette.applications import Starlette
api = Starlette()
api.add_middleware(MyMiddleware)
app = rx.App(api_transformer=api)Apps passed to api_transformer now receive ASGI lifespan events, so the lifespan, on_startup and on_shutdown handlers of a FastAPI or Starlette transformer run, and a transformer whose startup raises stops the server from starting. Check these after upgrading:
- If you worked around the missing lifespan by also registering the transformer's lifespan with
app.register_lifespan_task, remove that registration: it now runs the lifespan twice. - The
starlette_appargument of a lifespan task is now theStarletteapp that serves Reflex's own routes. Code that searched its routes for the transformer no longer finds it. - Raw ASGI middleware returned by a callable transformer now sees the
lifespanscope, which has nopath. Readscope.get("path")or checkscope["type"]first. Middleware that raises on it keeps the lifespans inside it from running, and Reflex logs that the app appears not to support lifespan events.
See Lifespan.
Database migrations
The reflex db commands now run through alembic and the app's alembic/env.py (#7742, #7809). reflex db init installs an env.py that loads the app and reads db_url from rxconfig.py, and reflex db migrate, makemigrations and status wrap alembic upgrade head, alembic revision --autogenerate and alembic history --indicate-current. See Migrations.
The env.py of an earlier release
Apps initialized by an earlier release have alembic's stock env.py, which earlier releases never ran. The next reflex db command, or migrate() call, replaces it with the current template and keeps the previous file as alembic/env.old (or env.old.1, and so on, if that name is taken). Existing revisions are kept, and no action is needed. Commit the new env.py and delete env.old once you no longer need it.
A hand-edited env.py
An env.py you edited by hand, one without the stock template's target_metadata = None line, is kept, and Reflex warns once that it is not the Reflex template. On 0.10 the reflex db commands ignored it. In 0.11 they execute it, so it must work on its own. A common failure is the placeholder URL in alembic.ini:
Alembic failed with NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:driver
alembic/env.py is not the Reflex template: move it aside and run reflex db init to write the current template.When it fails, reflex run logs the error and starts the app, and the reflex db commands exit with it. To adopt the template, move your file aside and run reflex db init, which writes a missing env.py and keeps the existing revisions. Then copy any customizations you still need into the new file:
mv alembic/env.py alembic/env.py.custom
reflex db initTo keep your own file instead, make it take the database URL from rxconfig.py, after config = context.config:
import reflex as rx
config.set_main_option("sqlalchemy.url", rx.config.get_config().db_url)A custom env.py also lacks what the template adds: the models of the whole app, server defaults for added columns, and the warning for dropped tables.
Model bases detected automatically
rx.ModelRegistry.register(Base) is now optional (#7797). Until a base is registered, reflex db and the alembic command line migrate the tables of every DeclarativeBase subclass defined at module level, or in a class body, of a module the app imports. A base meant for another database, or one defined by an installed library, then gets create_table operations in the migrations for db_url. Register the bases of db_url to leave the others out:
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
class AnalyticsBase(DeclarativeBase):
"""Tables of a separate analytics database."""
# Without this line, the next revision also creates AnalyticsBase's tables.
rx.ModelRegistry.register(Base)Registering a SQLModel class, such as rx.Model, does not turn detection off. Review autogenerated revisions before applying them: one that drops tables now logs a warning naming them.
Deprecated migration helpers
reflex.model.alembic_autogenerate, reflex.model.get_migration_history and reflex.model.format_revision, and the rx.Model.alembic_autogenerate and rx.Model.get_migration_history class methods, are deprecated and will be removed in 1.0. alembic_autogenerate now runs through alembic/env.py and ignores its connection argument. Use the reflex db commands, or their alembic equivalents:
Other migration changes
reflex db migrate,makemigrationsandstatusreport a missingdb_urlinstead of failing inside alembic, and exit with status 1 when there is no database to migrate or it is not initialized. Scripts that run them where no database is configured may need a guard.reflex db makemigrationsrefuses to add a NOT NULL column whose only default is a dataclassdefault_factory, as inmapped_column(default_factory=...)on aMappedAsDataclassmodel, and names the column. Give the column aserver_default, a literaldefault=, or make it nullable.reflex db makemigrationsand the startup schema check evaluate every page first, so a model that only a page imports now appears in generated revisions.
Upgrading reflex-enterprise
Upgrade reflex-enterprise to 0.9.12 together with Reflex 0.11. Its requirement on reflex does not move it forward on its own, so name both:
pip install -U "reflex==0.11.0" "reflex-enterprise==0.9.12"0.9.12 fixes AG Grid API calls and aligned_grids failing with ReferenceError: refs is not defined, and the MCP OAuth consent redirect in dev. It also stops an MCPPlugin app from failing at reflex run --env prod startup now that deploy_url is unset by default. When the backend serves the frontend, the plugin's OAuth issuer and OpenAPI servers entry fall back to api_url, so set REFLEX_API_URL, or MCPPlugin(issuer_url=...), to the app's public origin. rxe.event(auth=..., rate_limit=...) needs 0.9.11 or later on Reflex 0.11.
Other changes
- A form control without a
namestill submits its value under itsid, with a deprecation warning. The removal of that fallback moves from 0.11 to 0.12 (#7789). Give each control anamebefore then. rx.foreachwarns at compile time when an item withon_mount,on_unmountor hooks of its own is keyed by its index, the default (#7820). After an item is removed, itson_unmountruns for the item at the last position. Set a unique, stablekey, such askey=item.id, on the component the render function returns.- In dev mode, Reflex warns when an event handler or other task blocks the event loop for longer than
event_loop_blocking_thresholdseconds,0.2by default (#7672). Move blocking I/O to a thread or an async API, or set the threshold to0to turn detection off. - In production, Granian replaces a backend worker that dies instead of stopping the server (#7803). Set
GRANIAN_RESPAWN_FAILED_WORKERS=falseto keep the old behavior, for example when a process supervisor restarts the server. - With the Redis state manager, the state changes a foreground handler made before it raised or was cancelled are now kept, as with the other state managers (#7412, #7696). On 0.10 Redis discarded them.
- Generated pages no longer import the
refsobject from$/utils/state. Code that readsrefs[...]in a rawVar,rx.call_functionor anadd_hooksstring keeps working through a global, butrefsis internal and may change without notice (#7806). Act on a component byidwith event actions such asrx.set_value,rx.set_focusandrx.scroll_to.