Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
945a53f
feat: allow explicit only event handlers
benedikt-bartscher Sep 2, 2026
689dc48
Merge remote-tracking branch 'upstream/main' into disable-implicit-ev…
benedikt-bartscher Sep 3, 2026
4ca1a0a
Merge branch 'main' into disable-implicit-event-handlers
benedikt-bartscher Sep 10, 2026
acc796d
fix: load config on demand when resolving State-class flags so backen…
benedikt-bartscher Sep 10, 2026
bfa63ac
test: guard rxconfig module cleanup in on-demand config flag test
benedikt-bartscher Sep 10, 2026
77fe965
fix: export State-class flags to backend workers via env instead of l…
benedikt-bartscher Sep 10, 2026
abd7ca3
Merge remote-tracking branch 'upstream/main' into disable-implicit-ev…
benedikt-bartscher Sep 12, 2026
c20404d
Merge branch 'main' into disable-implicit-event-handlers
benedikt-bartscher Sep 15, 2026
d81a948
Merge branch 'main' into disable-implicit-event-handlers
benedikt-bartscher Sep 16, 2026
ded1464
Merge commit '006543ab32d53be0e245bababfa9b0b8c016c1fe' into merge-ma…
benedikt-bartscher Sep 21, 2026
1e31e0c
Raise a targeted error when an undecorated state method is wired to a…
benedikt-bartscher Sep 27, 2026
7f4cec0
Merge upstream/main into disable-implicit-event-handlers
benedikt-bartscher Sep 27, 2026
a80e5a1
Only mark undecorated methods defined in the state class body
benedikt-bartscher Sep 27, 2026
5fa41a1
Mark only undecorated methods defined in the owning class body
benedikt-bartscher Sep 27, 2026
d87a81b
Merge remote-tracking branch 'upstream/main' into disable-implicit-ev…
benedikt-bartscher Sep 29, 2026
b9137c6
Keep the event handler predicate free of side effects
benedikt-bartscher Sep 29, 2026
6d256f5
Merge remote-tracking branch 'upstream/main' into disable-implicit-ev…
benedikt-bartscher Sep 30, 2026
6004f74
Merge remote-tracking branch 'upstream/main' into disable-implicit-ev…
benedikt-bartscher Oct 2, 2026
964e0da
Merge remote-tracking branch 'upstream/main' into disable-implicit-ev…
benedikt-bartscher Oct 20, 2026
b9c3e71
Merge remote-tracking branch 'upstream/main' into disable-implicit-ev…
benedikt-bartscher Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/events/events_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ Whenever the user hovers over the heading, the `next_word` **event handler** wil

Adding the `@rx.event` decorator above the event handler is strongly recommended. This decorator enables proper static type checking, which ensures event handlers receive the correct number and types of arguments.

By default, every public method of a state (one whose name does not start with `_`) is treated as an event handler, whether or not it is decorated. To make `@rx.event` mandatory and keep undecorated public methods as plain Python helpers, enable `state_explicit_event_handlers` in `rxconfig.py`:
Comment thread
benedikt-bartscher marked this conversation as resolved.

```python
config = rx.Config(
app_name="my_app",
state_explicit_event_handlers=True,
)
```

This also applies to states from third-party packages, so they must decorate their event handlers for the option to be usable in your app.

The option is applied when a state class is created. `reflex run` forwards it to its backend workers; other entry points that import your state modules before the config is loaded, such as a test suite, can set the `REFLEX_STATE_EXPLICIT_EVENT_HANDLERS` environment variable instead.

## What's in this section?

In the event section of the documentation, you will explore the different types of events supported by Reflex, along with the different ways to call them.
1 change: 1 addition & 0 deletions news/7033.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the `state_explicit_event_handlers` config option. When enabled, only methods decorated with `@rx.event` become event handlers; other public state methods stay plain Python methods.
Comment thread
benedikt-bartscher marked this conversation as resolved.
1 change: 1 addition & 0 deletions packages/reflex-base/news/7033.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the `state_explicit_event_handlers` config option. When enabled, only methods decorated with `@rx.event` become event handlers; other public state methods stay plain Python methods.
98 changes: 77 additions & 21 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import urllib.parse
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
from collections.abc import Iterable, Iterator, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from importlib.machinery import PathFinder
from pathlib import Path, PureWindowsPath
from types import ModuleType
Expand Down Expand Up @@ -186,6 +187,7 @@ class BaseConfig:
redis_token_expiration: Token expiration time for redis state manager.
env_file: Path to file containing key-values pairs to load into the environment; Dotenv format. Multiple files may be separated by os.pathsep. Requires the python-dotenv package.
state_auto_setters: Whether to automatically create setters for state base vars.
state_explicit_event_handlers: Whether only methods decorated with `@rx.event` become event handlers. By default every public method of a user-defined state is an event handler.
default_color_mode: The default color mode for the app: "system" (follow the OS preference), "light", or "dark". Applies to the built-in color mode switcher and `color_mode_cond` without requiring a radix theme.
show_built_with_reflex: Whether to display the sticky "Built with Reflex" badge on all pages.
is_reflex_cloud: Whether the app is running in the reflex cloud environment.
Expand Down Expand Up @@ -269,6 +271,8 @@ class BaseConfig:

state_auto_setters: bool = False

state_explicit_event_handlers: bool = False

default_color_mode: LiteralColorMode = "system"

show_built_with_reflex: bool | None = None
Expand Down Expand Up @@ -436,10 +440,10 @@ def _post_init(self, **kwargs):
self._non_default_attributes = set(kwargs.keys())
self._replace_defaults(**kwargs)

# Publish for State-class creation so it never re-enters get_config()
# (which AttributeErrors if a State is defined while rxconfig.py is mid-import).
global _state_auto_setters
_state_auto_setters = self.state_auto_setters
# Publish to the in-progress rxconfig.py import, if any, so States defined
# after this Config in rxconfig.py resolve their flags from it.
if (load := _config_load.get()) is not None:
Comment thread
benedikt-bartscher marked this conversation as resolved.
load.config = self
Comment thread
benedikt-bartscher marked this conversation as resolved.

if (
self.state_manager_mode == constants.StateManagerMode.REDIS
Expand Down Expand Up @@ -854,6 +858,14 @@ def _set_persistent(self, **kwargs):
self._non_default_attributes.update(kwargs)
self._replace_defaults(**kwargs)

def _persist_state_flags(self):
"""Export the State-class creation flags to the environment for subprocesses.

Backend workers import the app module before any config is loaded, so
their user States resolve these flags from the environment.
"""
self._set_persistent(**{name: getattr(self, name) for name in _STATE_FLAGS})
Comment thread
benedikt-bartscher marked this conversation as resolved.


# Project-local modules first imported while loading rxconfig.py, and the
# project root they were recorded under. Evicted before a load from a different
Expand Down Expand Up @@ -949,29 +961,68 @@ def _record_imports() -> Iterator[_ImportRecorder]:
# Protect sys.path from concurrent modification during config loading.
_load_config_lock = threading.RLock()

# Cached state_auto_setters so State-class creation never re-enters get_config().
_state_auto_setters: bool | None = None
# Config fields resolved at State-class creation time via _get_state_flag.
_STATE_FLAGS = ("state_auto_setters", "state_explicit_event_handlers")


def get_state_auto_setters() -> bool:
"""Return whether state auto-setters are enabled, without importing rxconfig.
@dataclasses.dataclass
class _ConfigLoad:
"""An in-progress rxconfig.py import, holding its Config once constructed."""

config: Config | None = None


Reads the value cached when the Config was built. Before any Config exists
(e.g. a State defined inside rxconfig.py during its import), falls back to the
REFLEX_STATE_AUTO_SETTERS env var, then the default (False). This never calls
get_config() or imports rxconfig, so it cannot re-enter config loading.
# Set for the duration of _get_config() so States defined inside rxconfig.py see
# the Config being loaded (or env/default before it exists), never a stale one.
_config_load: ContextVar[_ConfigLoad | None] = ContextVar("_config_load", default=None)


def _get_state_flag(name: str) -> bool:
"""Resolve a boolean State-class creation flag without loading rxconfig.

Uses the Config of the in-progress rxconfig.py import, else the Config cached
on the active RegistrationContext, else the REFLEX_<NAME> env var (which
`reflex run` sets for its backend workers), then False. Never loads rxconfig,
since an rxconfig.py may import the very module defining the State.

Args:
name: The config field name.

Returns:
Whether state auto-setters are enabled.
The resolved flag value.
"""
if _state_auto_setters is not None:
return _state_auto_setters
env_val = os.environ.get(Config._prefixes[0] + "STATE_AUTO_SETTERS")
load = _config_load.get()
config = (
load.config
if load is not None
else RegistrationContext.ensure_context()._config
)
if config is not None:
return getattr(config, name)
env_val = os.environ.get(Config._prefixes[0] + name.upper())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the env var is not set and no config is cached, the flag falls back to False with no warning. Every public method then becomes an event handler again. This happens for any entry point other than reflex run, for example uvicorn/gunicorn pointed at the app module, or a test suite that imports the state modules.

Repro with an app that sets state_explicit_event_handlers=True, and no REFLEX_STATE_EXPLICIT_EVENT_HANDLERS in the environment:

from uvicorn.importer import import_from_string
import_from_string("pr7033_repro.pr7033_repro:app")
sorted(ExplicitState.event_handlers)
# [..., 'mixin_helper', ..., 'step', ...]  <- undecorated helpers are handlers

The docs mention this. But for a security switch, a silent fallback to the permissive mode is the wrong failure. Please make it fail closed. For example: record the value the first user State was built with. When the config is loaded (e.g. in App.__init__), raise if the config value differs.

if env_val and env_val.strip():
return interpret_env_var_value(env_val, bool, "state_auto_setters")
return interpret_env_var_value(env_val, bool, name)
return False


def get_state_auto_setters() -> bool:
"""Return whether state auto-setters are enabled, without importing rxconfig.

Returns:
Whether state auto-setters are enabled.
"""
return _get_state_flag("state_auto_setters")


def get_state_explicit_event_handlers() -> bool:
"""Return whether only `@rx.event` methods become event handlers, without importing rxconfig.

Returns:
Whether explicit event handlers are required.
"""
return _get_state_flag("state_explicit_event_handlers")


def _get_config(project_root: Path | None = None) -> Config:
"""Import rxconfig.py fresh from the project root and return its config.

Expand All @@ -998,6 +1049,7 @@ def _get_config(project_root: Path | None = None) -> Config:
# which removal by value could confuse with caller-owned ones.
cwd = str(project_root)
sys.path.insert(0, cwd)
load_token = _config_load.set(_ConfigLoad())
try:
# Never cache rxconfig itself — each load goes to disk so different
# RegistrationContexts hold independent Config instances.
Expand Down Expand Up @@ -1030,6 +1082,7 @@ def _get_config(project_root: Path | None = None) -> Config:
)
return rxconfig.config
finally:
_config_load.reset(load_token)
for i, entry in enumerate(sys.path):
if entry is cwd:
del sys.path[i]
Expand Down Expand Up @@ -1094,13 +1147,16 @@ def get_config(reload: bool = False) -> Config:
def reload_config() -> Config:
"""Force a fresh load of the config into the current RegistrationContext.

Clears any cached config on the current context and reloads rxconfig.py
from disk.
Reloads rxconfig.py from disk and replaces any cached config on the current
context. If the load fails, the context keeps its previous config.

Returns:
The freshly loaded app config.
"""
ctx = RegistrationContext.ensure_context()
config = _get_config()
ctx._set_config(config)
# Load and publish under one lock so concurrent reloads of a shared context
# cannot publish an older load over a newer one.
with _load_config_lock:
config = _get_config()
ctx._set_config(config)
return config
35 changes: 34 additions & 1 deletion packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ def _scan_detach(value: Any, memo: dict[int, Any], active: set[int]) -> Any:
BACKGROUND_TASK_MARKER = "_reflex_background_task"
SUPERSEDES_MARKER = "_reflex_supersedes"
EVENT_ACTIONS_MARKER = "_rx_event_actions"
EVENT_MARKER = "_rx_event"
UNDECORATED_STATE_METHOD_MARKER = "_rx_undecorated_state_method"
UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles"

# Payload key listing the names of the extra bound handler args in an upload
Expand Down Expand Up @@ -2395,6 +2397,28 @@ def check_fn_match_arg_spec(
raise EventFnArgMismatchError(msg)


def _check_not_undecorated_state_method(value: Any, key: str | None) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check runs only when a trigger is compiled. The runtime paths through Event.from_event_type and fix_events do not call it. Both cases below work in implicit mode, so users who migrate will hit them:

  • app.add_page(page, on_load=State.undecorated) is accepted. Every load of the page then fails in the backend with ValueError: Unexpected event type, <class 'function'>. The browser shows no error, and on_load does not run.
  • An event handler that does return State.undecorated (or yields it) fails with TypeError: Your handler State.chain must only return/yield: None, Events....

Neither message mentions @rx.event or state_explicit_event_handlers. Please call this check on the runtime path too, so that on_load and return/yield values get the same targeted error. Please also add tests for both cases.

"""Reject a state method that explicit event handler mode left undecorated.

Args:
value: The value wired to the event trigger.
key: The name of the event trigger.

Raises:
EventHandlerValueError: If value is an undecorated state method.
"""
if getattr(value, UNDECORATED_STATE_METHOD_MARKER, False):
from reflex_base.utils.exceptions import EventHandlerValueError

msg = (
f"{value.__qualname__} is wired to {key or 'an event trigger'} but is not an "
"event handler. Decorate it with `@rx.event`: with "
"`state_explicit_event_handlers` enabled, undecorated methods stay plain "
"methods."
)
raise EventHandlerValueError(msg)


def event_sequence(events: Any) -> Sequence[Any]:
"""Get events as a sequence, wrapping a single event-like value in a list.

Expand Down Expand Up @@ -2436,12 +2460,15 @@ def call_event_fn(
The event-like values from calling the function.

Raises:
EventHandlerValueError: If the lambda returns an unusable value.
EventHandlerValueError: If fn is an undecorated state method or returns
an unusable value.
"""
# Import here to avoid circular imports.
from reflex_base.event import EventHandler, EventSpec
from reflex_base.utils.exceptions import EventHandlerValueError

_check_not_undecorated_state_method(fn, key)

parsed_args, _ = parse_args_spec(arg_spec)

parameters = inspect.signature(fn).parameters
Expand Down Expand Up @@ -2528,6 +2555,7 @@ def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar:

# Make sure the event spec is valid.
if not isinstance(e, (EventSpec, FunctionVar, EventVar)):
_check_not_undecorated_state_method(e, key)
hint = ""
if isinstance(e, VarOperationCall):
hint = " Hint: use `fn.partial(...)` instead of calling the FunctionVar directly."
Expand Down Expand Up @@ -3163,6 +3191,8 @@ class EventNamespace:
BACKGROUND_TASK_MARKER = BACKGROUND_TASK_MARKER
SUPERSEDES_MARKER = SUPERSEDES_MARKER
EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER
EVENT_MARKER = EVENT_MARKER
UNDECORATED_STATE_METHOD_MARKER = UNDECORATED_STATE_METHOD_MARKER
Comment on lines +3194 to +3195

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two markers are internal, but adding them to EventNamespace makes them public as rx.event.EVENT_MARKER and rx.event.UNDECORATED_STATE_METHOD_MARKER. reflex/state.py imports them from reflex_base.event directly, so the namespace entries are not needed. Please remove them, so that no new public API has to be supported.

_EVENT_FIELDS = _EVENT_FIELDS
FORM_DATA = FORM_DATA
FORM_SUBMIT_MAPPING = FORM_SUBMIT_MAPPING
Expand Down Expand Up @@ -3287,6 +3317,9 @@ def wrapper(
if getattr(func, "__name__", "").startswith("_"):
msg = "Event handlers cannot be private."
raise ValueError(msg)
# Lets State tell decorated methods apart when
# state_explicit_event_handlers is enabled.
setattr(func, EVENT_MARKER, True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setattr runs in the default mode too. So rx.event(x) now raises for any x without a __dict__:

rx.event(Obj().m)
# AttributeError: 'method' object has no attribute '_rx_event' and no __dict__ for setting new attributes
rx.event(len)
# AttributeError: 'builtin_function_or_method' object has no attribute '_rx_event' ...

On main, both calls return the value unchanged. I did not find this usage in the repo or the docs, but it is a behavior change for users who do not enable the flag. Please guard the setattr, or give a clear error.


qualname: str | None = getattr(func, "__qualname__", None)

Expand Down
41 changes: 35 additions & 6 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@
)

from reflex_base import constants
from reflex_base.config import get_state_auto_setters, get_state_explicit_event_handlers
from reflex_base.constants.state import FIELD_MARKER
from reflex_base.environment import PerformanceMode, auto_reload_cooldown, environment
from reflex_base.event import (
EVENT_ACTIONS_MARKER,
EVENT_MARKER,
UNDECORATED_STATE_METHOD_MARKER,
Event,
EventHandler,
EventSpec,
Expand Down Expand Up @@ -768,8 +771,11 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs):
)
raise StateValueError(msg)

explicit = cls.is_user_defined() and get_state_explicit_event_handlers()
cls._bind_fields()
cls._bind_mixin_members()
cls._bind_mixin_members(explicit)
if explicit:
cls._mark_undecorated_methods()

# Set the base and computed vars.
cls.base_vars = {
Expand Down Expand Up @@ -803,7 +809,7 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs):

# Set up the event handlers.
for name, fn in list(cls.__dict__.items()):
if cls._item_is_event_handler(name, fn):
if cls._item_is_event_handler(name, fn, explicit):
handler = cls._create_event_handler(fn)
cls.event_handlers[name] = handler
setattr(cls, name, handler)
Expand Down Expand Up @@ -845,11 +851,14 @@ def _bind_fields(cls) -> None:
fields[name] = f

@classmethod
def _bind_mixin_members(cls) -> None:
def _bind_mixin_members(cls, explicit: bool) -> None:
"""Copy the computed vars and event handler functions of mixins onto this class.

A member is copied only where this class resolves the name to the mixin's
member, so this class or an earlier base overrides it as usual.

Args:
explicit: Only copy event handler functions decorated with `@rx.event`.
"""
for mixin_cls in cls._mixins():
for name, value in mixin_cls.__dict__.items():
Expand All @@ -864,7 +873,7 @@ def _bind_mixin_members(cls) -> None:
_var_data=VarData.from_state(cls),
),
)
elif cls._item_is_event_handler(name, value):
elif cls._item_is_event_handler(name, value, explicit):
fn = cls._copy_fn(value)
fn.__qualname__ = f"{cls.__name__}.{name}"
setattr(cls, name, fn)
Expand Down Expand Up @@ -909,12 +918,13 @@ def _copy_fn(fn: Callable) -> Callable:
return newfn

@staticmethod
def _item_is_event_handler(name: str, value: Any) -> bool:
def _item_is_event_handler(name: str, value: Any, explicit: bool = False) -> bool:
"""Check if the item is an event handler.

Args:
name: The name of the item.
value: The value of the item.
explicit: Only count functions decorated with `@rx.event`.

Returns:
Whether the item is an event handler.
Expand All @@ -925,8 +935,27 @@ def _item_is_event_handler(name: str, value: Any) -> bool:
and not isinstance(value, EventHandler)
and not getattr(value, "__override_base_method__", False)
and hasattr(value, "__code__")
and (not explicit or getattr(value, EVENT_MARKER, False))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A decorator without functools.wraps on top of @rx.event loses this marker. So the method silently stops being an event handler:

def require_login(f):
    def inner(self, *a, **k):
        return f(self, *a, **k)
    return inner

class S(rx.State):
    @require_login
    @rx.event
    def save(self): ...

rx.button(on_click=S.save)
EventFnArgMismatchError: Event on_click only provides 1 arguments, but <function require_login.<locals>.inner ...> requires at least 3 arguments to be passed to the event handler.

The wrapper's __qualname__ is require_login.<locals>.inner, so _mark_undecorated_methods also skips it, and the targeted error does not fire. The same code works on main. It also works with functools.wraps, or with @rx.event as the outer decorator.

Please give this case a clear error that names the flag. Or at least document that @rx.event must be the outermost decorator, or that wrappers must use functools.wraps.

)

@classmethod
def _mark_undecorated_methods(cls) -> None:
"""Mark the undecorated methods of this class and its mixins.

Wiring a marked method to an event trigger raises a targeted error. Only
methods defined in the class body are marked: a callback defined
elsewhere may be shared.
"""
for owner in (cls, *cls._mixins()):
prefix = owner.__dict__.get("__original_qualname__", owner.__qualname__)
for name, value in owner.__dict__.items():
if (
cls._item_is_event_handler(name, value)
and not getattr(value, EVENT_MARKER, False)
and value.__qualname__ == f"{prefix}.{name}"
):
setattr(value, UNDECORATED_STATE_METHOD_MARKER, True)

@classmethod
def _evaluate(cls, f: Callable[[Self], Any], of_type: type | None = None) -> Var:
"""Evaluate a function to a ComputedVar. Experimental.
Expand Down Expand Up @@ -1008,6 +1037,7 @@ def _handle_local_def(cls):
proposed_name = f"{cls.__name__}_{ix}"
setattr(reflex.istate.dynamic, proposed_name, cls)
cls.__original_name__ = cls.__name__
cls.__original_qualname__ = cls.__qualname__
cls.__original_module__ = cls.__module__
cls.__name__ = cls.__qualname__ = proposed_name
cls.__module__ = reflex.istate.dynamic.__name__
Expand Down Expand Up @@ -1292,7 +1322,6 @@ def _init_var(cls, name: str, prop: Var):
Raises:
VarTypeError: if the variable has an incorrect type
"""
from reflex_base.config import get_state_auto_setters
from reflex_base.utils.exceptions import VarTypeError

if not types.is_valid_var_type(prop._var_type):
Expand Down
Loading
Loading