Promote client state to rx.client_state with a scoped store

Merged
MA
3d ago
+6422-707

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

Description

Promote client-only state to rx.client_state with a store-backed React runtime.

What changes for users

  • rx.client_state(default, name=...) is the public API. A named var is global: any component can read and write it, and the backend can push and retrieve it. An unnamed var is owned by the outermost page or @rx.memo body that uses it, so each @rx.memo instance owns its own value, the way React's useState does, and a page's unnamed vars reset when the page unmounts.
  • Writes are per var: setting one var re-renders only the components that read it.
  • .set takes a value, a FunctionVar, or a lambda traced at compile time (count.set(lambda v: v + 1)), and replaces .set_value.
  • rx._x.client_state and ClientStateVar.create(var_name, default, global_ref) keep working with a deprecation warning. An unnamed legacy var stays global unless global_ref=False was passed.
  • Server-side rendering gets a fresh store per request.

Implementation

  • reflex_base.client_state: ClientStateVar, ClientStateSetter, the client_state() factory.
  • .templates/web/utils/client_state.js: slots with independent subscriptions, a scope chain that resolves a name to the slot its owning scope holds, and getClientState / setClientState for code outside the React tree.
  • The owner of an unnamed var is decided at compile time. Each page and each @rx.memo body that uses client state is wrapped in a ClientStateScope that lists the unnamed names its body uses (owns=[...]); ClientStateScopePlugin builds a page's list from the compile walk, including bodies the memoize plugin moved into their own modules. A scope claims each listed name before its descendants render unless an ancestor owns it, so the owner never depends on render order. Auto-memoize wrappers stay invisible to scoping.
  • ClientStateProvider app wrap provides the root scope; state.js publishes the store on refs["__client_state"] for backend events and rx.call_script code.
  • New tests/js/ vitest harness for the shipped frontend JavaScript, wired into the unit-tests workflow.

Not in this PR

Per-item scoping of rx.foreach loop vars (providing the item and index to handlers and memoized descendants, and isolating each item in its own component) moves to a follow-up PR. Until it lands, per-item client state inside a loop is written with an @rx.memo item function, which the docs show.

Checklist

  • Tests pass with adequate coverage (unit, JS, Playwright)
  • uv run ruff check . and uv run ruff format . clean
  • uv run pyright reflex tests passes
  • pyi_hashes.json updated
  • Documentation updated
  • News fragments added
  • Deprecation warnings added for the old API

https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9


Generated by Claude Code