Skip to content

Implement client state with useClientState hook - #6936

Draft
masenf wants to merge 9 commits into
mainfrom
claude/clientstatevar-context-refactor-jv3pig
Draft

Implement client state with useClientState hook#6936
masenf wants to merge 9 commits into
mainfrom
claude/clientstatevar-context-refactor-jv3pig

Conversation

@masenf

@masenf masenf commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Type of change

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

Description

This PR implements a new client-side state management system using React's useClientState hook, replacing the previous useState-based approach. The implementation provides:

Key Features

  1. 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.

  2. 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.

  3. Backend Integration: Global client state vars can be pushed from the backend and retrieved via new wire events (_client_state_set, _client_state_get).

  4. Functional Updaters: Setters accept lambdas that are traced at compile time, enabling cs.set(lambda v: v + 1) patterns with proper typing.

  5. 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 access
  • ClientStateProvider: App-wrap component that mounts the provider and publishes the store
  • ClientStateScope: Scope boundary component for per-item state in loops
  • Compiler Integration: Auto-memoization of client state setters, hook/import/app-wrap collection via VarData

Backward Compatibility

The original reflex.experimental.client_state.ClientStateVar API is preserved with a deprecation warning. The new rx.client_state(default, name=...) signature is the recommended path forward.

Testing

  • 704 lines of JavaScript unit tests (tests/js/client_state.test.js) covering store slots, subscriptions, SSR isolation, provider lifecycle, and scope behavior
  • 646 lines of Python unit tests (tests/units/reflex_base/test_client_state.py) covering hook emission, imports, app wraps, setters, and functional updaters
  • 232 lines of Playwright integration tests (tests/integration/tests_playwright/test_client_state.py) covering runtime behavior, backend push/retrieve, and per-item state in loops
  • Updated memoization tests to account for loop-item scope handling

Documentation

  • Updated docs/wrapping-react/overview.md with new API and scoping examples
  • Updated docs/library/dynamic-rendering/foreach.md with per-item state patterns

Checklist

  • Tests pass with adequate coverage (unit + integration)
  • uv run ruff check . and uv run ruff format . clean
  • uv run pyright reflex tests passes
  • pyi_hashes.json updated
  • Documentation updated
  • Deprecation warnings added for old API

https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9

Review in cubic

claude added 9 commits August 22, 2026 01:02
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
@codspeed-hq

codspeed-hq Bot commented Aug 24, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 10.17%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 6 regressed benchmarks
✅ 21 untouched benchmarks
⏩ 8 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

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)

Open in CodSpeed

Footnotes

  1. 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-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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.

  • Adds the useClientState store, provider, scoped-value runtime, and per-slot subscriptions.
  • Adds the Python client-state API, functional setters, global accessors, and backend push/retrieve events.
  • Updates foreach and memo compilation so loop values and per-item client state survive generated component boundaries.
  • Adds JavaScript, Python, and Playwright coverage plus public documentation and CI execution for frontend template tests.

Confidence Score: 4/5

The 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

Important Files Changed

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

Comment on lines +384 to +396
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"];

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.

P2 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants