Skip to content

Implement client state with useClientState hook - #6936

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

Implement client state with useClientState hook#6936
masenf wants to merge 20 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 5.82%

❌ 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] 27 ms 29.1 ms -7.07%
Simulation test_compile_page_full_context[_stateful_page] 34.6 ms 37 ms -6.45%
Simulation test_compile_page[_stateful_page] 30.6 ms 32.7 ms -6.35%
Simulation test_evaluate_page[_stateful_page] 4.6 ms 4.8 ms -5.5%
Simulation test_evaluate_page_with_hooks[_stateful_page] 4.8 ms 5.1 ms -5.38%
Simulation test_get_all_imports[_stateful_page] 543.1 µs 566.5 µs -4.13%

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 (b9d7a5e) with main (f7c848f)

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

The PR introduces scoped client-side state with React hooks, backend read/write events, SSR-isolated stores, and compiler support for memoized components and loop scopes.

  • Adds the rx.client_state Python API and generated JavaScript runtime.
  • Integrates client-state scopes with memoization and foreach.
  • Adds JavaScript, Python, and Playwright coverage plus documentation.
  • Centralizes the client-state registry key for provider publication and backend event access.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported registry-key duplication is fixed because provider publication and event lookup now use the shared JavaScript constant, while the generated Python expression uses the matching Python constant.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js Implements scoped stores, per-slot subscriptions, provider publication, loop values, and the client-state hook.
packages/reflex-base/src/reflex_base/.templates/web/utils/state.js Uses the shared frontend registry-key constant for backend client-state read and write events.
packages/reflex-base/src/reflex_base/client_state.py Defines the stable Python client-state API, setters, functional updater tracing, and backend integration.
packages/reflex-base/src/reflex_base/constants/state.py Provides the Python-side client-state registry-key constant used by generated expressions.
tests/js/client_state.test.js Covers store behavior and verifies that JavaScript and Python registry-key definitions remain synchronized.
packages/reflex-components-core/src/reflex_components_core/core/foreach.py Integrates per-item scope and scoped loop values into foreach rendering.

Reviews (10): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread packages/reflex-base/src/reflex_base/.templates/web/utils/state.js Outdated
…uage

The `refs` key the provider publishes its store on was spelled out three
independent times in source: the `CLIENT_STATE_REF` constant in
`client_state.js`, two hardcoded literals in `state.js`, and the Python
expression `client_state.py` emits. A partial rename would have disconnected
the backend event handlers from the mounted provider.

There are now exactly two definitions, one per language, because neither can
import the other's: `CLIENT_STATE_REF` in `client_state.js` and
`CLIENT_STATE_REF` in `reflex_base.constants.state`. `state.js` imports the
frontend constant instead of respelling it -- the cycle its old comment warned
about no longer exists, since the provider takes the registry as a prop rather
than importing `refs`.

A vitest case asserts the two constants are equal by reading the Python source,
so a rename on either side fails loudly, and a second one keeps `state.js` from
regressing to a hardcoded copy. Both directions verified by mutation.

Also ports the late-mount regression test from #6824 (issue #6823): a consumer
mounting after a value has been pushed reads the live value rather than seeding
a copy from the default and then sitting stuck when the value returns to that
default. This holds by construction here -- there is one slot per name and a
late consumer binds to it -- and the test fails if slot claiming re-seeds. The
unit tests from that PR are not ported: they assert hook strings from the
per-component `useState` design this branch replaces.

Adds changelog fragments, and skips lockfiles in codespell so the new
`tests/js/package-lock.json` integrity hashes do not trip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
@masenf

masenf commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

On the CodSpeed compile regression

The regression is real and caused by this PR. I don't think it should block, and here's the accounting.

Every regressed benchmark is [_stateful_page], and that fixture (tests/benchmarks/fixtures.py) is foreach-heavy — _simple_foreach() plus _nested_foreach(). Nothing else regressed.

Mechanism. Foreach used to be a snapshot boundary, which sealed its whole subtree from auto-memoization. It no longer does, so a loop-var consumer becomes its own memo component. Compiling _stateful_page goes from 9 to 13 memo definitions — one extra per loop-var consumer per loop. Each definition costs a body render plus a _compute_memo_tag content hash, and that is where the time goes.

That extra memoization is the fix, not overhead next to it. It's what puts a loop-var consumer in its own component so a hoisted useCallback can still read the item (#3210), and what gives each rendered item its own client-state scope. The old path was cheaper because it did strictly less; there's no version of this fix that keeps the subtree sealed.

It's compile-time only — build and hot reload, not the served app. At runtime the change goes the other way: loop items are now individually memoized, so a list update re-renders the items that changed rather than rebuilding every item's subtree.

Follow-up worth doing separately

Profiling _stateful_page locally, _build_wrapper is ~59% of compile, and inside it _compute_memo_tag is ~30% and _analyze_params ~14%. _analyze_params runs twice per memo definitioncreate_passthrough_component_memo evaluates the memo body once to compute the tag, then _create_component_definition evaluates it again (its own docstring notes this). Removing that second evaluation would recover most of this regression and speed up every memo in every app, not just foreach ones.

I've deliberately left it out of this PR: it touches the memo tag-stability contract, which decides generated module filenames, and it deserves its own change with its own tests rather than riding along here. Happy to open an issue or pick it up next.

Since the cost is inherent to the fix, this wants an acknowledgement on CodSpeed rather than a code change — I don't have access to do that.


Generated by Claude Code

@masenf

masenf commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Analyze (python) (CodeQL) failed on 362efa43, and it is not this PR's.

The analysis itself completed — every query was interpreted and the SARIF was written:

Exported results to SARIF (214ms)
...
Uploading results
##[warning]Connect Timeout Error (attempted address: api.github.com:443, timeout: 10000ms)
##[error]Connect Timeout Error (attempted address: api.github.com:443, timeout: 10000ms)
...
CodeQL job status was failure.

It died in the upload step on a network timeout to api.github.com, after analysis had already succeeded — so there are no findings involved and nothing in the diff could cause or fix it. Analyze (actions) on the same commit passed.

I've re-run the failed jobs in that workflow once (run 32881954542). If it fails the same way again I'll treat it as real and dig further rather than re-running a second time.

For the record, the only other red check on this commit is CodSpeed, which I addressed separately above. Everything else that has finished is green, including pre-commit, changelog, and js-unit-tests.


Generated by Claude Code

`create_passthrough_component_memo` analyzes the passthrough's params to
evaluate a preview body for the tag, then `_create_component_definition`
analyzed the same function's params again. Analyzing resolves type hints, and
the compiler builds one of these per auto-memo wrapper, so the second pass was
pure waste.

`_create_component_definition` now takes the already-analyzed params, and the
one caller that has them passes them through.

Measured on the `_stateful_page` compile benchmark, which this branch regressed
by widening auto-memoization into `rx.foreach` subtrees. Judged by cProfile
call counts, which are exactly reproducible -- wall clock on the dev box is
useless at this delta (interleaved A/B mins overlap completely):

    before  46,195 calls/compile   get_type_hints x26
    after   43,348 calls/compile   get_type_hints x13   (-6.2%)

`_create_component_definition` drops from 12.8% to 4.8% of compile cumtime.
The generated output is byte-identical -- page plus all 13 memo modules diffed
across the change.

This is a partial offset, not a fix for the whole regression. Profiling puts
the remaining cost in the memo tag hash: `_compute_memo_tag` is ~35% of compile
cumtime, dominated by `_update_deterministic_hash` at ~1,140 recursive calls
and ~3,000 hashlib updates per compile. Reusing the already-evaluated preview
as the definition body was also measured (a further -3.1%) and deliberately
not taken: it makes the definition body the same object `_compute_memo_tag`
rendered, and `render` can mutate, so the coupling is subtle rather than absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
@masenf

masenf commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Correcting my earlier comment: I said the double-evaluation in create_passthrough_component_memo was staying out of this PR. Having measured it, I've taken half of it in 70b34c5 — the cheap, output-identical half — and left the risky half out.

I judged this by cProfile call counts rather than wall clock, because wall clock on a dev box is meaningless at this delta (interleaved A/B/A/B/A/B mins overlapped completely: 7.21–8.33 ms against 6.54–8.42 ms). Call counts are exactly reproducible.

Per compile of the _stateful_page benchmark fixture (13 memo wrappers):

total calls/compile get_type_hints
before 46,195 26
after 43,348 (−6.2%) 13
also reusing the preview body 41,905 (−9.3%) 13

_create_component_definition goes from 12.8% to 4.8% of compile cumtime. Generated output is byte-identical — page plus all 13 memo modules diffed.

What I did not take, and why: reusing the already-evaluated preview as the definition's body is worth a further −3.1%, but it makes the definition body the same object _compute_memo_tag already rendered, and render can mutate (there is a test covering add_hooks filling derived props). Output was identical on the inputs I tried, which is not proof. Not worth the coupling for a third of the benefit.

Where the cost actually is. Not in either double-evaluation. _compute_memo_tag is ~35% of compile cumtime, and the tottime leader across the whole compile is _update_deterministic_hash — roughly 1,140 recursive calls and 3,000 hashlib updates per compile. Anything that meaningfully closes the remaining gap has to come from there, and it deserves its own change.

So the regression is reduced, not eliminated. The rest still wants either an acknowledgement or a follow-up on the tag hash.


Generated by Claude Code

@masenf

masenf commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

CodSpeed on 70b34c5c confirms the prediction: overall regression 10.04% → 5.53%, and the compile benchmarks roughly halved.

Benchmark before 70b34c5 after
test_compile_all_artifacts[_stateful_page] 32.2 ms (−16.46%) 28.9 ms (−6.82%) ✅ halved
test_compile_page_full_context[_stateful_page] 40 ms (−13.93%) 36.6 ms (−6.12%) ✅ halved
test_compile_page[_stateful_page] 35.7 ms (−14.76%) 32.4 ms (−6.05%) ✅ halved
test_evaluate_page[_stateful_page] −5.21% −5.16% unchanged
test_evaluate_page_with_hooks[_stateful_page] −5.03% −4.82% unchanged
test_get_all_imports[_stateful_page] −3.92% −4.18% unchanged

The −6.2% I measured in interpreter call counts landed as ~8–9 points of wall-clock recovery on the compile benchmarks, so the deterministic metric tracked the real thing well.

The split in that table is worth reading: only the compile benchmarks moved, because they are the ones that build memo definitions. The three that didn't — page evaluation and import collection — never touch _create_component_definition. Their ~5% is the loop-scope plumbing itself: every loop var now carries VarData with a hook and an import, and each item's subtree gets a provider. That part is the fix, and it isn't going anywhere.

So the remaining 5.53% is roughly: ~6% on compile (the extra memo definitions, of which the tag hash is the dominant cost — see above) and ~5% on evaluation (loop-var VarData). Both are the mechanism that makes #3210 work. Still wants an acknowledgement, but at half the size.


Generated by Claude Code

claude added 4 commits August 25, 2026 19:06
Audited the registry for retained values and added the guards. No leak found,
but the invariants that make that true were untested, and two of them are easy
to break.

What holds a slot: a scope's `owned` map. Scopes point *up* to their parent and
a parent keeps no list of children, so an unmounted `ClientStateScope` -- one
`rx.foreach` item, one `@rx.memo` instance -- takes its map and every slot in it
out of reach. Verified a loop adds nothing to the root scope as its list churns,
so the store cannot grow with the number of items ever rendered.

Root-scope slots are the deliberate exception: they outlive their consumers,
because a named var is app-wide and the backend can push to it with nothing
mounted -- releasing on last unmount would undo the late-mount behavior ported
from #6824. Their number is fixed at compile time by the named and page-level
`rx.client_state` call sites, so the retention is bounded, not unbounded.

That exception makes one thing load-bearing: a listener on a root slot that
outlived its component would pin that component's React internals for the life
of the page. Tested by wrapping the slot's subscribe and asserting the count
returns to zero on unmount.

The remaining behavior is React's, and it is worth stating: keys decide what a
row's state belongs to. Under positional keys -- what `rx.foreach` emits by
default -- changing the list re-renders rows in place rather than unmounting
them, so a row's client state stays with the position, not the item. Keyed by
identity the old rows unmount and their state is released. Both directions are
now tested, and the docs say so, since a loop item could not hold state before
this branch.

All three invariants fail under mutation: a no-op unsubscribe, a `ScopedValues`
that stops opening a scope, and an item scope that resolves to the root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
A row's loop values and a row's client state behave differently when the list
contents are replaced, and the difference is easy to mistake for a bug, so both
are now asserted against a running app.

The loop item and index update. They resolve through the scope the loop
provides on every render rather than being captured at mount, so replacing
["a","b","c"] with ["d","e","f"] moves them even though the default positional
key means React re-renders the existing rows instead of mounting new ones.
Verified by mutation: freezing the provided values fails this test.

A row's client state does not. The default seeds the slot when the row first
claims it and is not re-read, and a positional key means the row never
unmounts, so nothing re-claims -- `useState(props.item)` semantics. This holds
for a state the row was given and for one seeded from the item, and the test
asserts it deliberately: re-seeding whenever the default changed would silently
throw away whatever the user had typed into the row.

`key=` on the item is the way to tie state to the item instead. Keyed by
identity the old rows unmount, releasing their scopes, and the new rows seed
from the new items. Covered by the third test.

Also considered and rejected: resetting a row's scope when its provided values
change, instead of relying on keys. For a list of dicts every unrelated state
update yields fresh object references, so scopes would reset continuously and
all per-item state would evaporate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
`rx.foreach` keys rows by position, and that stays the default: a list of
interchangeable slots wants "row 3 is expanded" to persist as data flows
through, and keying by identity needs a unique key expression, which Reflex
cannot guarantee for an arbitrary list.

The cost of that default was under-documented. A client state default is a seed,
read once when the row claims its slot, so a row seeded from its item keeps the
*old* seed when the list's contents change -- the row never unmounts, so nothing
re-claims. The item itself follows the data; only the seeded state lags, which
makes the two easy to confuse:

    State.items: ["a", "b", "c"] -> ["d", "e", "f"]
      rx.text(item)                renders d, e, f
      rx.client_state(item).value  renders a, b, c

Both docs now show that side by side and point at `key=` for tying state to the
item, or an explicit `on_mount` set for tracking a var while staying editable in
between. Also says when each keying choice is the right one, and that identity
keys need unique keys.

Behavior is unchanged: seeding once is what keeps a re-render from discarding
what the user typed into a row, and both directions are already covered by
integration tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…-context-refactor-jv3pig

One conflict, in `reflex/experimental/client_state.py`: main reworded a property
docstring there (#6893, the ruff/pyright/typer upgrade) while this branch had
replaced the whole file with a deprecation shim over
`reflex_base.client_state`. Resolved in favor of the shim -- the implementation
that docstring belonged to no longer exists here.

That upgrade also brought the rule behind the reword, and this branch's own
`ClientStateVar.value` tripped it. Fixed the same way main fixed theirs.

Verified against the merged base with the upgraded tooling (ruff 0.16.3,
pyright 1.1.411): pre-commit 7/7, 7762 unit tests, 40 vitest, 24 Playwright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
claude added 4 commits August 27, 2026 21:55
…-context-refactor-jv3pig

Conflicts resolved as unions:

- ``components/memo.py``: ``MemoComponentDefinition`` keeps
  ``is_instance_boundary`` alongside main's ``auto_memo_wrapper`` and
  ``display_name``; ``create_passthrough_component_memo`` keeps the
  pre-analyzed ``params`` pass-through together with main's replacements.
- ``tests_playwright/test_memo.py``: main's ``framed`` memo page section plus
  the scoped/nested/mutable/keyed sections from this branch.
- ``pyi_hashes.json``: regenerated.

``test_user_memo_inside_foreach_is_not_independently_memoized`` asserted a
premise main's auto-memoization of stateful user memos supersedes: a user
``@rx.memo`` with a loop-var prop now gets its own wrapper module rather than
being inlined into the foreach body. Retargeted to the behavior that still
matters — the wrapper resolves the loop var through ``useScopedValue`` and
lands beside the foreach snapshot, never on the page.
…-context-refactor-jv3pig

Restores the second parent that the previous commit dropped, and picks up
#6941 (release automation moved into the ``reflex-release`` package).

The previous merge of ``origin/main`` resolved correctly but was recorded with
a single parent, so every commit on main's side of that merge read as belonging
to this branch — 120 files against a true diff of a fraction of that. The four
files that conflict here (``components/memo.py``, ``pyi_hashes.json``,
``tests_playwright/test_memo.py``, ``compiler/test_memoize_plugin.py``) are
untouched by main since, so their resolutions carry over unchanged.
…-context-refactor-jv3pig

Picks up #6605 (memo ``RestProp`` CSS routing) and #6924 (``reflex deploy``
moved to the Cloud CLI).

``_create_component_definition`` takes the union: this branch's optional
pre-analyzed ``params`` guard plus the ``rest_target_fields`` out-parameter
#6605 threads into ``_evaluate_component_body``.
…-context-refactor-jv3pig

Picks up #6937 and #6962, both confined to reflex-hosting-cli. Clean
auto-merge; nothing touches client state, memoization, or foreach.
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