Implement client state with useClientState hook - #6936
Conversation
ClientStateVar expanded into eight lines of generated hook code per var and
kept its state in four `refs` keys, alongside DOM refs, upload controllers and
the toaster. Those writes happened during render rather than in an effect, the
per-instance setter dicts were never cleaned up on unmount, and every write
fanned out to every registered setter, so writing one var re-rendered
components reading a different one.
Replace it with a single `useClientState` hook over a store of independently
subscribable slots, delivered by a React context provider injected through the
existing `VarData.app_wraps` pipeline. The store keeps one debuggable
`refs["__client_state"]` entry, and per-slot subscriptions via
`useSyncExternalStore` mean a write only re-renders that var's subscribers.
Also:
- Promote the API out of `experimental`: it lives in reflex-base and is
exposed as `rx.client_state`; `reflex.experimental.client_state` re-exports
it, so existing imports keep working.
- Collapse `.set` and `.set_value` into `.set`, which is now callable.
`.set` attaches bare to a trigger, `.set(value)` binds a value, and
`.set(lambda v: ...)` traces a functional updater against a placeholder typed
from the var, so ordinary var operations work inside it. `.set_value` remains
as a deprecated alias.
- Add `.global_value` / `.global_set`, a supported escape hatch for driving a
client state var from JS outside the React tree.
- Replace the eval'd `run_script` used by `push`/`retrieve` with first-class
`_client_state_set` / `_client_state_get` events, and reuse one extracted
callback helper across the `applyEvent` result-callback sites.
- Suffix the emitted JS identifier with a marker so a name can never collide
with a reserved word (`rx.client_state("class")` was a syntax error), and fix
`.set`'s arg-name recovery to key on Reflex's marker convention instead of a
`_` prefix -- so any valid identifier is a legal name, and event args are
recovered from compound expressions too.
- Generate omitted names from a dedicated counter, so a name no longer shifts
when unrelated code draws from the process-wide name generator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Three issues found reviewing the previous commit: - A named var with no default emitted `useClientState(, "name")`, a syntax error that breaks the page build, because the store name is passed as a second argument and the empty default rendered as nothing. Emit an explicit `undefined`. - `push` sent its value as a JSON payload, so a `Var` -- a client-side expression -- arrived as its own source text instead of being evaluated. Route a Var through the evaluated path via `refs["__client_state"]`, keeping the JSON payload for concrete values. - `getClientStore` memoized a module-level store on the server too, so if the provider were ever absent the SSR fallback could carry a value between requests. Return a fresh store when there is no `document`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…, exports - `_client_state_get` returned early when no provider was mounted, leaving a handler awaiting `retrieve` blocked on a result that would never arrive. Call back with undefined instead: it may fail, but it fails visibly. - Several providers can share one store (an embedded app rendered alongside a main app), so the first to unmount deleted the `refs` entry out from under the others. Reference-count mounted providers and only drop it on the last. - Export `ClientStateSetter` so the type `.set` returns can be named in an annotation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
The python suites can only see this code through compiled output, and an integration test cannot reach behavior that needs no running app -- provider teardown, the SSR branch, subscription bookkeeping. Two fixes in this branch landed untested for exactly that reason. Adds `tests/js/`, deliberately outside `.templates/web` since everything in there is copied verbatim into generated apps. `$/...` specifiers resolve to the template tree via a vitest alias; `$/utils/state` is stubbed, because the real module pulls in socket.io, react-router and the per-app generated `context.js`. Scoped to `client_state.js` for now -- `state.js` needs those stubs before it is unit-testable, and the integration tests already cover its interaction end to end. Nineteen tests covering slot semantics (per-var listener isolation, updaters, equal-value bail, create-on-write, unsubscribe), `getClientStore` client singleton vs. per-call on the server, provider refcounting across several mounted providers and StrictMode's double mount, `useClientState` sharing and isolation, and the non-React escape hatch. Each of the three behaviors these were written for was confirmed to fail the intended test when the fix is reverted. Runs as a `js-unit-tests` job in the existing unit-tests workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
- `client_state.js` no longer imports `refs` from `$/utils/state`. The provider takes the object to publish its store on as a `registry` prop, which the python side supplies as the `refs` Var carrying its own import. The module is now independent of where that lives, so the python side can move it without touching this javascript. Its unit tests pass their own object, so the `$/utils/state` stub is gone too. - `__hash__` now includes `_state_name` and `_global_ref`. Two vars differing only in those compared equal, despite carrying materially different VarData. - The `create` docstring described scoping incorrectly. A named var is readable and writable from any component and from the backend; an anonymous one is private to the component its hook is emitted in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Client state was two tiers selected by `global_ref`, and the anonymous tier did
not survive Reflex's own compiler: because touching a client state var is itself
a memoization trigger, every consumer compiles to its own React component, so an
anonymous var read in one place and written in another became two disconnected
slots. An ordinary stateful sibling was enough to trigger it. The page compiled
and simply did not work.
Names now resolve down a scope chain. A scope owns some names and delegates the
rest to its parent; the first component in a tree to use a name claims it for its
descendants. Separate instances of a boundary get separate state, everything
under one boundary shares, and optimizer-generated boundaries stay invisible --
so a subtree split across memo modules keeps resolving the same slot and no memo
code has to be refactored.
Which tier you get follows from whether you name the var, so `global_ref` is
gone: a named var resolves at the root scope and stays reachable from the backend
via `push` / `retrieve` / `global_value` / `global_set`; an unnamed one is owned
by the tree that first uses it. Where you *construct* the var decides who shares
it, mirroring React's lifted state -- and because construction happens once per
call at compile time, a plain helper function called N times yields N independent
states with no memo, no keys and no configuration.
The boundary is emitted as an HOC on the memo definition's existing `wrapper`
extension point, not as a provider inside its returned JSX: a component's hooks
run before its own output mounts, so an inner provider would leave the memo's own
`useClientState` resolving against the enclosing scope and sharing across
instances. A new `is_instance_boundary` flag on `MemoComponentDefinition`, set
only by `@rx.memo`, keeps auto-memo wrappers transparent, and the wrap is gated
on the subtree actually using client state so pages don't pay per memo.
Also:
- The new API is `rx.client_state(default, *, name=None, prefix="cs")` -- a
single positional default, reading like `useState`. Putting `default` first
matters now that the first argument decides global vs scoped:
`rx.client_state("default")` used to look like a value while naming the var.
`prefix` customizes generated names to keep compiled output readable.
- `rx._x.client_state` keeps the original signature and carries every deprecation
notice, so the new API has none. Its `global_ref=False` drops the name, which
reproduces the old anonymous behavior exactly under the new rules.
- 27 vitest tests for the scope chain and 3 compiler tests for the emission
gating; each behavior was confirmed to fail its intended test when the
corresponding piece is reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
The wrapping-react page still described the retired `global_ref` model. Explain what actually decides sharing now: naming a var makes it global, an unnamed one is scoped to the tree that uses it, and *where you construct it* picks the owner -- including the consequence that a page-level read collapses per-instance state below it. Covers the plain-helper case and `prefix=`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
`rx.foreach` rendered its item and index as the `.map` callback's parameters, which went out of scope the moment anything referencing them compiled into its own function -- an `on_submit` lifted into a `useCallback`, or a subtree lifted into its own memo module. The page threw `ReferenceError: index is not defined` (#3210), and the documented workaround was a hidden form input. Each rendered item is now wrapped in a `ScopedValues` provider that publishes the item and index by name, and a loop var carries a `useScopedValue` read for them. The hook declares the same identifier the callback binds, so inside the loop body the parameter shadows it (where the parameter is the real value) and anywhere else the context read wins. For that to reach the consumers, `Foreach` stops being a snapshot *boundary* and becomes only a structural snapshot child: its subtree is user content, so it keeps memoizing and each consumer lands in its own module below the per-item provider. The subtree is walked with a memoize-only hook chain, so no page-level collector sees it -- its hooks, imports, refs and custom code still belong to the memo body that renders it, and the page stays free of the loop scope. The provider is the element the map yields, so it is what React reconciles the list by and therefore what carries the key: an explicit key on the item is lifted onto it, otherwise the index keys by position as before. An auto-memo wrapper now also inherits the key of the component it replaces, which a keyed item root would otherwise lose. `ScopedValues` opens a client state scope too, since one rendered item is one component instance. An unnamed `rx.client_state` var in a `foreach` body is therefore per item, the way `useState` would be in a React list, which closes the inline-foreach gap in client state scoping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
`rx.client_state(initial_value)` where the default is a Var -- the obvious way to seed per-item state from a loop index -- emitted `useClientState(ix_rx_state_, "cs3")` in every consumer module with nothing declaring `ix_rx_state_` and no `useScopedValue` import, so each item seeded from `undefined`. `ClientStateVar.create` read `default_var._var_data`, the var's own field. A derived or cast default keeps its hooks and imports on the var it wraps, reachable only through `_get_all_var_data()` -- a loop var is `scoped_loop_var(...).guess_type()`, whose cast wrapper has no var data of its own. Not loop-specific: a state var default lost its `useContext(StateContexts…)` the same way. Ordering holds by construction -- `VarData.merge` builds hooks in argument order and the pair travels inside one `VarData`, so the declaration cannot land after the line that reads it. The default is a seed, read once when the scope claims the name, so it does not track the var afterwards. Documented, along with reading an enclosing loop's item from a nested body, which works as long as the inner loop does not reuse the name -- the rule Python already imposes by shadowing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Merging this PR will degrade performance by 10.17%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Simulation | test_compile_all_artifacts[_stateful_page] |
26.9 ms | 32.2 ms | -16.48% |
| ❌ | Simulation | test_compile_page[_stateful_page] |
30.4 ms | 35.7 ms | -14.82% |
| ❌ | Simulation | test_compile_page_full_context[_stateful_page] |
34.4 ms | 40 ms | -14% |
| ❌ | Simulation | test_evaluate_page[_stateful_page] |
4.5 ms | 4.8 ms | -5.26% |
| ❌ | Simulation | test_evaluate_page_with_hooks[_stateful_page] |
4.7 ms | 5 ms | -5% |
| ❌ | Simulation | test_get_all_imports[_stateful_page] |
542.7 µs | 568.7 µs | -4.58% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/clientstatevar-context-refactor-jv3pig (fca7ce7) with main (d86f167)
Footnotes
-
8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
Greptile SummaryThis PR introduces scoped and globally addressable client-side state, integrates it with backend events, memoization, and foreach scopes, and preserves the former experimental API through deprecation.
Confidence Score: 4/5The PR appears safe to merge, with only a non-blocking maintainability issue around the duplicated client-state registry key. The implemented state, provider, foreach, memoization, and backend-event paths have substantial focused coverage, while the only accepted concern is that three independently hardcoded copies of the registry identifier can drift during a future change. Files Needing Attention: packages/reflex-base/src/reflex_base/.templates/web/utils/state.js and packages/reflex-base/src/reflex_base/client_state.py
|
| Filename | Overview |
|---|---|
| packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js | Adds the client-side store, provider lifecycle, scope chain, foreach value context, and useClientState subscription hook. |
| packages/reflex-base/src/reflex_base/client_state.py | Adds the stable Python client-state API, generated hooks, typed setters, global access, and backend event integration. |
| packages/reflex-base/src/reflex_base/.templates/web/utils/state.js | Adds backend client-state event handling and factors callback evaluation into a shared helper; registry access duplicates its identifier literal. |
| packages/reflex-base/src/reflex_base/components/tags/iter_tag.py | Changes loop variables to carry scoped-value hooks so extracted components can resolve enclosing items and indices. |
| packages/reflex-components-core/src/reflex_components_core/core/foreach.py | Propagates row identity and scoped loop metadata through foreach rendering. |
| reflex/compiler/plugins/memoize.py | Preserves client-state scopes, app wraps, keys, and structural descendants across generated memo boundaries. |
| packages/reflex-base/src/reflex_base/compiler/templates.py | Emits ScopedValues around each mapped row so descendants can resolve loop values and receive per-item state scope. |
| tests/js/client_state.test.js | Adds extensive runtime coverage for slots, subscriptions, provider lifecycle, SSR isolation, and scope behavior. |
| tests/integration/tests_playwright/test_client_state.py | Exercises browser behavior, backend exchange, functional updates, and per-item state in compiled applications. |
Reviews (1): Last reviewed commit: "fix(client_state): carry a Var default's..." | Re-trigger Greptile
| const store = refs["__client_state"]; | ||
| if (store === undefined) { | ||
| console.error( | ||
| `Cannot set client state "${event.payload.var_name}": no ClientStateProvider is mounted.`, | ||
| ); | ||
| } else { | ||
| store.set(event.payload.var_name, event.payload.value); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| if (event.name == "_client_state_get") { | ||
| const store = refs["__client_state"]; |
There was a problem hiding this comment.
Centralize the client-state registry key
The backend event branches hardcode "__client_state" separately from CLIENT_STATE_REF and the generated Python expression. Keeping these copies synchronized creates unnecessary maintenance cost and allows a partial rename to disconnect backend reads and writes from the mounted provider.
Rule Used: String literals that are used as identifiers or ke... (source)
Learned From
reflex-dev/flexgen#2170
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Type of change
Description
This PR implements a new client-side state management system using React's
useClientStatehook, replacing the previoususeState-based approach. The implementation provides:Key Features
Scoped Client State: Named client state vars are global and addressable from the backend; unnamed vars are scoped to the component tree that first uses them, enabling per-item state in loops without naming collisions.
Per-Slot Subscriptions: Each state var has its own listener set, so writing one var only re-renders components subscribed to that specific var—not all components using client state.
Backend Integration: Global client state vars can be pushed from the backend and retrieved via new wire events (
_client_state_set,_client_state_get).Functional Updaters: Setters accept lambdas that are traced at compile time, enabling
cs.set(lambda v: v + 1)patterns with proper typing.SSR Isolation: Server-side rendering gets a fresh store per request, preventing state leakage between requests.
Implementation Details
reflex_base.client_state: Core Python API (ClientStateVar,ClientStateSetter,client_state()factory)utils/client_state.js: React runtime with scope chain, slot management, and store accessClientStateProvider: App-wrap component that mounts the provider and publishes the storeClientStateScope: Scope boundary component for per-item state in loopsVarDataBackward Compatibility
The original
reflex.experimental.client_state.ClientStateVarAPI is preserved with a deprecation warning. The newrx.client_state(default, name=...)signature is the recommended path forward.Testing
tests/js/client_state.test.js) covering store slots, subscriptions, SSR isolation, provider lifecycle, and scope behaviortests/units/reflex_base/test_client_state.py) covering hook emission, imports, app wraps, setters, and functional updaterstests/integration/tests_playwright/test_client_state.py) covering runtime behavior, backend push/retrieve, and per-item state in loopsDocumentation
docs/wrapping-react/overview.mdwith new API and scoping examplesdocs/library/dynamic-rendering/foreach.mdwith per-item state patternsChecklist
uv run ruff check .anduv run ruff format .cleanuv run pyright reflex testspassespyi_hashes.jsonupdatedhttps://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9