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 5.82%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
Greptile SummaryThe 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.
Confidence Score: 5/5The 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.
|
| 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
…-context-refactor-jv3pig
…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
On the CodSpeed compile regressionThe regression is real and caused by this PR. I don't think it should block, and here's the accounting. Every regressed benchmark is Mechanism. 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 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 separatelyProfiling 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 |
|
The analysis itself completed — every query was interpreted and the SARIF was written: It died in the upload step on a network timeout to 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 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
|
Correcting my earlier comment: I said the double-evaluation in 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
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 Where the cost actually is. Not in either double-evaluation. 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 |
|
CodSpeed on
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 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 Generated by Claude Code |
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
…-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``.
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