From 89352ac10e7a55a2406fd25170ce3dfe0522270f Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 16 May 2024 13:20:26 -0700 Subject: [PATCH] rx._x.client_state: react useState Var integration for frontend and backend (#3269) New experimental feature to create client-side react state vars, save them in the global `refs` object and access them in frontend rendering/event triggers as well on the backend via call_script. --- reflex/experimental/__init__.py | 2 + reflex/experimental/client_state.py | 198 ++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 reflex/experimental/client_state.py diff --git a/reflex/experimental/__init__.py b/reflex/experimental/__init__.py index 29bda8545..6972fdfe0 100644 --- a/reflex/experimental/__init__.py +++ b/reflex/experimental/__init__.py @@ -8,6 +8,7 @@ from reflex.components.sonner.toast import toast as toast from ..utils.console import warn from . import hooks as hooks +from .client_state import ClientStateVar as ClientStateVar from .layout import layout as layout from .misc import run_in_thread as run_in_thread @@ -16,6 +17,7 @@ warn( ) _x = SimpleNamespace( + client_state=ClientStateVar.create, hooks=hooks, layout=layout, progress=progress, diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py new file mode 100644 index 000000000..d0028991c --- /dev/null +++ b/reflex/experimental/client_state.py @@ -0,0 +1,198 @@ +"""Handle client side state with `useState`.""" + +import dataclasses +import sys +from typing import Any, Callable, Optional, Type + +from reflex import constants +from reflex.event import EventChain, EventHandler, EventSpec, call_script +from reflex.utils.imports import ImportVar +from reflex.vars import Var, VarData + + +def _client_state_ref(var_name: str) -> str: + """Get the ref path for a ClientStateVar. + + Args: + var_name: The name of the variable. + + Returns: + An accessor for ClientStateVar ref as a string. + """ + return f"refs['_client_state_{var_name}']" + + +@dataclasses.dataclass( + eq=False, + **{"slots": True} if sys.version_info >= (3, 10) else {}, +) +class ClientStateVar(Var): + """A Var that exists on the client via useState.""" + + # The name of the var. + _var_name: str = dataclasses.field() + + # Track the names of the getters and setters + _setter_name: str = dataclasses.field() + _getter_name: str = dataclasses.field() + + # The type of the var. + _var_type: Type = dataclasses.field(default=Any) + + # Whether this is a local javascript variable. + _var_is_local: bool = dataclasses.field(default=False) + + # Whether the var is a string literal. + _var_is_string: bool = dataclasses.field(default=False) + + # _var_full_name should be prefixed with _var_state + _var_full_name_needs_state_prefix: bool = dataclasses.field(default=False) + + # Extra metadata associated with the Var + _var_data: Optional[VarData] = dataclasses.field(default=None) + + def __hash__(self) -> int: + """Define a hash function for a var. + + Returns: + The hash of the var. + """ + return hash( + (self._var_name, str(self._var_type), self._getter_name, self._setter_name) + ) + + @classmethod + def create(cls, var_name, default=None) -> "ClientStateVar": + """Create a local_state Var that can be accessed and updated on the client. + + The `ClientStateVar` should be included in the highest parent component + that contains the components which will access and manipulate the client + state. It has no visual rendering, including it ensures that the + `useState` hook is called in the correct scope. + + To render the var in a component, use the `value` property. + + To update the var in a component, use the `set` property. + + To access the var in an event handler, use the `retrieve` method with + `callback` set to the event handler which should receive the value. + + To update the var in an event handler, use the `push` method with the + value to update. + + Args: + var_name: The name of the variable. + default: The default value of the variable. + + Returns: + ClientStateVar + """ + if default is None: + default_var = Var.create_safe("", _var_is_local=False, _var_is_string=False) + elif not isinstance(default, Var): + default_var = Var.create_safe(default) + else: + default_var = default + setter_name = f"set{var_name.capitalize()}" + return cls( + _var_name="", + _setter_name=setter_name, + _getter_name=var_name, + _var_is_local=False, + _var_is_string=False, + _var_type=default_var._var_type, + _var_data=VarData.merge( + default_var._var_data, + VarData( # type: ignore + hooks={ + f"const [{var_name}, {setter_name}] = useState({default_var._var_name_unwrapped})": None, + f"{_client_state_ref(var_name)} = {var_name}": None, + f"{_client_state_ref(setter_name)} = {setter_name}": None, + }, + imports={ + "react": {ImportVar(tag="useState", install=False)}, + f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], + }, + ), + ), + ) + + @property + def value(self) -> Var: + """Get a placeholder for the Var. + + This property can only be rendered on the frontend. + + To access the value in a backend event handler, see `retrieve`. + + Returns: + an accessor for the client state variable. + """ + return ( + Var.create_safe( + _client_state_ref(self._getter_name), + _var_is_local=False, + _var_is_string=False, + ) + .to(self._var_type) + ._replace( + merge_var_data=VarData( # type: ignore + imports={ + f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], + } + ) + ) + ) + + @property + def set(self) -> Var: + """Set the value of the client state variable. + + This property can only be attached to a frontend event trigger. + + To set a value from a backend event handler, see `push`. + + Returns: + A special EventChain Var which will set the value when triggered. + """ + return ( + Var.create_safe( + _client_state_ref(self._setter_name), + _var_is_local=False, + _var_is_string=False, + ) + .to(EventChain) + ._replace( + merge_var_data=VarData( # type: ignore + imports={ + f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], + } + ) + ) + ) + + def retrieve(self, callback: EventHandler | Callable | None = None) -> EventSpec: + """Pass the value of the client state variable to a backend EventHandler. + + The event handler must `yield` or `return` the EventSpec to trigger the event. + + Args: + callback: The callback to pass the value to. + + Returns: + An EventSpec which will retrieve the value when triggered. + """ + return call_script(_client_state_ref(self._getter_name), callback=callback) + + def push(self, value: Any) -> EventSpec: + """Push a value to the client state variable from the backend. + + The event handler must `yield` or `return` the EventSpec to trigger the event. + + Args: + value: The value to update. + + Returns: + An EventSpec which will push the value when triggered. + """ + return call_script(f"{_client_state_ref(self._setter_name)}({value})")