Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
e6fe6fa
feat(client_state): promote to rx.client_state on a React context
claude Aug 22, 2026
6225012
fix(client_state): valid codegen with no default, and two runtime fixes
claude Aug 22, 2026
b745638
fix(client_state): address review nits on retrieve, provider teardown…
claude Aug 22, 2026
047710e
test(js): add vitest unit tests for the shipped frontend javascript
claude Aug 22, 2026
b7dc16d
refactor(client_state): pass the registry in, and fix hash and docstring
claude Aug 22, 2026
7d80c8f
feat(client_state): scope client state by name down the component tree
claude Aug 22, 2026
c94d4b9
docs(client_state): document naming and tree scoping
claude Aug 22, 2026
73add07
feat(foreach): scope loop vars per item, fixing #3210
claude Aug 22, 2026
fca7ce7
fix(client_state): carry a Var default's hooks and imports
claude Aug 22, 2026
57b017b
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 25, 2026
362efa4
refactor(client_state): give the registry key one definition per lang…
claude Aug 25, 2026
70b34c5
perf(memo): stop analyzing one memo's params twice
claude Aug 25, 2026
8a4fb6e
test(client_state): pin down what frees a client state slot
claude Aug 25, 2026
f01da50
test(foreach): pin what a list change does to a row's values and state
claude Aug 25, 2026
5abe947
docs(foreach): spell out the stale-seed caveat under positional keys
claude Aug 25, 2026
2f538da
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 25, 2026
5efdd80
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 27, 2026
1000b0f
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 27, 2026
4099719
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
b9d7a5e
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
c2d3b78
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
657e5fe
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
c3cf471
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
5c82e45
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
acfa5af
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
1ba7886
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
46b3ca8
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
e03ef30
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Aug 28, 2026
c59c7bf
Merge remote-tracking branch 'origin/main' into codex/pr-6936
masenf Sep 16, 2026
ead24d8
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Sep 16, 2026
703b28a
fix: declare the addEvents alias where the callback is eval'd
claude Sep 16, 2026
97cf561
Merge remote-tracking branch 'origin/main' into codex/pr-6936
masenf Sep 16, 2026
1bf14fe
Bind serialized client-state callbacks to the current event dispatcher
masenf Sep 16, 2026
d6ca3df
fix: also alias addEvents in applyEvent's own eval scope
claude Sep 16, 2026
f6251ee
Merge concurrent client-state callback dispatch fix
masenf Sep 16, 2026
7be165c
Merge remote-tracking branch 'origin/claude/clientstatevar-context-re…
masenf Sep 16, 2026
089e5d6
test: assert every user-code eval scope declares the addEvents alias
claude Sep 16, 2026
a3d63de
Merge remote-tracking branch 'origin/main' into codex/pr-6936
masenf Sep 17, 2026
42300f4
Merge remote-tracking branch 'origin/main' into codex/pr-6936
masenf Sep 17, 2026
1f515a7
Merge origin/main into claude/clientstatevar-context-refactor-jv3pig
claude Oct 7, 2026
b048af5
fix(components-internal): floor reflex at 0.9.13.dev0 for rx.client_s…
claude Oct 7, 2026
f3a5fd2
fix(experimental): keep the legacy ClientStateVar shim a plain subclass
claude Oct 7, 2026
c669965
Merge origin/main into claude/clientstatevar-context-refactor-jv3pig
claude Oct 23, 2026
5ce5534
test(client_state): assert the state hook by its declaration, not its…
claude Oct 23, 2026
ae3593d
Keep unnamed legacy rx._x.client_state vars global
claude Oct 23, 2026
d641b2d
Render every foreach item through its own component
claude Oct 23, 2026
31ff217
Drop the explicit memo body walk; document the loop-in-memo case
claude Oct 23, 2026
d297f06
Hash a forced snapshot memo body by its whole subtree
claude Oct 27, 2026
ce75353
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Oct 27, 2026
3b94f00
Raise sibling floors past the 0.10.1 release; refresh memo.pyi hash
claude Oct 27, 2026
268cd02
Tighten comments and news fragments
claude Oct 28, 2026
ea7de06
Move the foreach loop-var scoping to a follow-up
claude Oct 28, 2026
a715564
Separate generated client state names from their prefix with $
claude Oct 28, 2026
dd94b01
Merge remote-tracking branch 'origin/main' into claude/clientstatevar…
claude Oct 28, 2026
6cd51ff
Refresh the memo.pyi hash for the merged lock
claude Oct 28, 2026
b5681fb
Declare trigger arguments a setter value reads from placeholders
claude Oct 29, 2026
0662361
Find trigger args through nested lambdas; derive position from the si…
claude Oct 29, 2026
f6beab9
Reach the client state store without a mounted provider
claude Oct 30, 2026
15ff5f7
Decide the owner of a local client state var at compile time
FarhanAliRaza Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,28 @@ jobs:
- name: Generate coverage report
run: uv run coverage html

js-unit-tests:
# Unit tests for the javascript Reflex ships in
# `reflex-base/.templates/web`, which the python suites can only reach
# through compiled output.
needs: changes
if: needs.changes.outputs.run == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
cache-dependency-path: tests/js/package-lock.json
- run: npm ci
working-directory: tests/js
- run: npm test
working-directory: tests/js

unit-tests-macos:
timeout-minutes: 30
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
Expand Down Expand Up @@ -162,7 +184,7 @@ jobs:
# jobs above directly does not survive: check names follow the matrix and the
# display names, and a job that a filter skips must not block the merge.
unit-tests-gate:
needs: [changes, unit-tests, unit-tests-macos]
needs: [changes, unit-tests, js-unit-tests, unit-tests-macos]
if: always()
runs-on: ubuntu-latest
steps:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ reflex.db
.env.*
node_modules
package-lock.json
# ...except the javascript test harness, which CI installs with `npm ci`.
!tests/js/package-lock.json
*.pyi
.pre-commit-config.yaml
.claude/.worktrees
Expand All @@ -40,3 +42,4 @@ CLAUDE.local.md

# Backups written by scripts/delete_automated_releases.sh
automated-releases-backup-*.json
tests/js/node_modules
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ uv run python scripts/check_min_deps.py --check-dev-pins [pkg] # fail if pkg
uv run reflex-release sync # regenerate the release workflows after editing [tool.reflex-release] or the reflex-release templates
uv run python scripts/make_pyi.py # regenerate .pyi stubs
uv run pre-commit run --all-files # all pre-commit hooks
npm --prefix tests/js ci && npm --prefix tests/js test # javascript unit tests (frontend templates)
```

## Layout
Expand All @@ -32,6 +33,7 @@ uv run pre-commit run --all-files # all pre-commi
reflex/ # main framework package (app, state, compiler, components, utils, istate)
packages/ # workspace sub-packages (reflex-base, reflex-components-*, reflex-docgen, reflex-components-internal)
tests/units/ # unit tests, mirrors source tree
tests/js/ # vitest unit tests for the shipped frontend javascript
tests/integration/ # Selenium integration tests (run in dev+prod modes)
tests_playwright/ # Playwright integration tests (preferred for new tests)
tests/benchmarks/ # performance benchmarks
Expand Down Expand Up @@ -59,6 +61,24 @@ docs/ # documentation site (separate workspace member)
- unit tests should primarily cover a single module, and should be named accordingly, including subdirectories (e.g. `tests/units/istate/test_manager.py` for `reflex/istate/manager.py`). For subpackages, also include the corresponding path below `src/` (e.g. `tests/units/reflex_base/event/test_context.py` for `packages/reflex-base/src/reflex_base/event/context.py`).
- **Integration tests:** prefer Playwright (`tests/integration/tests_playwright/`). Integration tests are slow — extend existing test apps rather than creating new ones for trivial functionality. Multiple test cases sharing one app is fine.

### Frontend javascript tests

The javascript Reflex ships lives in
`packages/reflex-base/src/reflex_base/.templates/web/` and is copied verbatim into a
user's `.web` directory, so tests must **not** live inside that tree. They go in
`tests/js/`, which has its own `package.json` and runs under vitest + jsdom:
`npm --prefix tests/js test`.

Reach for these when behavior can only be observed at runtime and an integration
test would be indirect or impossible to set up — provider teardown, SSR vs. client
branches, subscription bookkeeping. Prefer a Playwright test when the thing you
want to assert is visible in a real app.

`$/...` specifiers resolve to the template tree via a vitest alias. `$/utils/state`
is stubbed (`tests/js/stubs/state.js`) because the real module pulls in socket.io,
react-router and the per-app *generated* `utils/context.js`; a module needing those
is not currently unit-testable.

### Integration test patterns

Apps as factory functions, run via `AppHarness`:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import reflex as rx
import reflex_components_internal as ui
from reflex.experimental import ClientStateVar
from reflex_site_shared.integrations import get_integration_logo_url

from .integration_list import get_integration_path
from .integration_request import request_integration_dialog

selected_filter = ClientStateVar.create("selected_filter", "All")
selected_filter = rx.client_state("All", name="selected_filter")

FilterOptions = [
{"name": "AI", "icon": "BotIcon"},
Expand All @@ -32,7 +31,7 @@ def integration_filter_button(data: dict):
aria_pressed=active,
class_name="docs-integration-filter inline-flex min-h-10 items-center justify-center gap-2 rounded-full border px-4 py-2 text-sm font-normal leading-5 transition-colors motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring "
+ rx.cond(active, active_pill, inactive_pill).to(str),
on_click=selected_filter.set_value(data["name"]),
on_click=selected_filter.set(data["name"]),
)


Expand Down
7 changes: 3 additions & 4 deletions docs/app/reflex_docs/templates/docpage/docpage.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import reflex as rx
import reflex_components_internal as ui
from reflex.components.radix.themes.base import LiteralAccentColor
from reflex.experimental.client_state import ClientStateVar
from reflex.utils.format import to_snake_case, to_title_case
from reflex_site_shared.components.blocks.code import *
from reflex_site_shared.components.blocks.demo import *
Expand Down Expand Up @@ -121,7 +120,7 @@ def feedback_button_toc() -> rx.Component:

@rx.memo
def copy_to_markdown(text: rx.Var[str]) -> rx.Component:
copied = ClientStateVar.create("is_copied", default=False, global_ref=False)
copied = rx.client_state(False)
return marketing_button(
rx.cond(
copied.value,
Expand All @@ -136,10 +135,10 @@ def copy_to_markdown(text: rx.Var[str]) -> rx.Component:
variant="ghost",
class_name="justify-start pl-0 text-muted-foreground",
on_click=[
rx.call_function(copied.set_value(True)),
rx.call_function(copied.set(True)),
rx.set_clipboard(text),
],
on_mouse_down=rx.call_function(copied.set_value(False)).debounce(1500),
on_mouse_down=rx.call_function(copied.set(False)).debounce(1500),
)


Expand Down
5 changes: 2 additions & 3 deletions docs/library/data-display/icon.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ components:
import reflex as rx

from reflex_components_lucide.icon import LUCIDE_ICON_LIST
from reflex.experimental.client_state import ClientStateVar

icon_search_cs = ClientStateVar.create("icon_search", default="")
icon_search_cs = rx.client_state("", name="icon_search")


@rx.memo
Expand All @@ -26,7 +25,7 @@ def lucide_icons() -> rx.Component:
),
rx.el.input(
placeholder="Search icons...",
on_change=icon_search_cs.set_value,
on_change=icon_search_cs.set,
class_name="relative box-border border-border-subtle focus:border-border-strong focus:border-1 bg-muted p-[0.5rem_0.75rem] border rounded-xl font-base text-muted-foreground placeholder:text-subtle-foreground outline-none focus:outline-none w-full mb-2 pl-10",
),
class_name="relative flex items-center",
Expand Down
108 changes: 105 additions & 3 deletions docs/wrapping-react/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ We also have a var `color` which is the current color of the color picker.
Since this component has interaction we must specify any event triggers that the component takes. The color picker has a single trigger `on_change` to specify when the color changes. This trigger takes in a single argument `color` which is the new color.

```python exec
from reflex.experimental.client_state import ClientStateVar
from reflex.components.component import NoSSRComponent


Expand All @@ -73,15 +72,15 @@ class ColorPicker(NoSSRComponent):

color_picker = ColorPicker.create

ColorPickerState = ClientStateVar.create(default="#db114b", var_name="color")
ColorPickerState = rx.client_state("#db114b", name="color")
```

```python eval
rx.box(
ColorPickerState,
rx.vstack(
rx.heading(ColorPickerState.value, as_="h2", color="white"),
color_picker(on_change=ColorPickerState.set_value),
color_picker(on_change=ColorPickerState.set),
),
background_color=ColorPickerState.value,
padding="5em",
Expand Down Expand Up @@ -124,6 +123,109 @@ def index():
)
```

## Naming And Scoping Client State

Whether a client state var is shared app-wide follows from whether you name it:

```python
shared = rx.client_state(
"", name="search_query"
) # global: any component, and the backend
private = rx.client_state("") # scoped to the component tree using it
```

A **named** var resolves in one app-wide store, so any component can read and write it and
`push`, `retrieve`, `global_value` and `global_set` all work against it.

An **unnamed** var gets a compile-time name and is scoped: the outermost page or `@rx.memo`
whose body uses it owns it, so each instance of that component gets its own state, the way
React's `useState` does. Nothing outside the tree can address it, and a page's unnamed vars
start again from their defaults each time the page mounts. Name a var to keep its value
across pages.

Where you *construct* the var decides who shares it. Construct it inside a component and
each instance gets its own:

```python
@rx.memo
def copy_button(text: rx.Var[str]) -> rx.Component:
copied = rx.client_state(False) # one per rendered button
...
```

Construct it once at module level and reference it from several components and they share
it, owned by the outermost page or `@rx.memo` that uses it. This is the same as lifting state
up in React. Note the consequence: adding a read at page level makes the page the owner, which
collapses per-instance state below it into one shared value. The owner follows from where the
var is used, never from which component renders first.

A plain helper function called several times gives each call its own state for free, since
the var is constructed once per call:

```python
def counter():
count = rx.client_state(0) # a distinct var per call
return rx.hstack(
rx.button("-", on_click=count.set(lambda v: v - 1)),
rx.heading(count.value),
rx.button("+", on_click=count.set(lambda v: v + 1)),
)


rx.vstack(counter(), counter(), counter()) # three independent counters
```

Inside `rx.foreach`, give the item its own state by making the item function an
`@rx.memo` component; each rendered instance then owns its unnamed vars:

```python
@rx.memo
def row(item: rx.Var[str]) -> rx.Component:
expanded = rx.client_state(False) # one per rendered row
...


rx.foreach(State.items, lambda item: row(item=item))
```

Pass `prefix=` to make generated names readable in the compiled output:
`rx.client_state(0, prefix="counter")`.

## Setting Client State From Plain JavaScript

`value` and `set` are the normal way to use a client state var, but they resolve to a
hook, so they only work inside a component that Reflex renders. When you are wrapping a
library that hands you a plain JavaScript callback -- or you are writing your own JS in
`add_custom_code` -- use `global_value` and `global_set` instead. They need no hook, so
they work anywhere in your compiled page:

```python
picker_color = rx.client_state("#db114b", name="picker_color")


class MyPicker(rx.Component):
library = "some-non-react-picker"
tag = "Picker"

def add_custom_code(self) -> list[str]:
# `global_set` is a plain function, so a non-React callback can call it.
return [f"const onPickerChange = {picker_color.global_set};"]
```

Reads through `global_value` are a point-in-time snapshot with no reactivity, so prefer
`value` inside components. Writes through `global_set` re-render every component
subscribed to that var, exactly like `set` does. Both require a **named** client state
var, since the name is what identifies the value.

`rx.call_script` is the one place these do not work: its code is evaluated inside the
Reflex runtime module, so your page's imports are not in scope there. Reach the store
through the `refs` object instead, which is also how you inspect client state from the
browser devtools console:

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.

refs is a module export of $/utils/state, not a global. In the devtools console, refs["__client_state"] fails with ReferenceError: refs is not defined. Please remove the console claim, or give a path that works in the console.


```python
rx.call_script('refs["__client_state"].set("picker_color", "#ffffff")')
```

## What Not To Wrap

There are some libraries on npm that are not do not expose React components and therefore are very hard to wrap with Reflex.
Expand Down
1 change: 1 addition & 0 deletions news/6936.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `rx.client_state` for client-only state. A named var (`rx.client_state("", name="query")`) is global: any component can read and write it, and the backend can `push` and `retrieve` it. An unnamed var is owned by the outermost page or `@rx.memo` that uses it, so each `@rx.memo` instance gets its own value and a page's values reset when it unmounts. Setting a var re-renders only the components that read it. `.set` takes a value, a `FunctionVar`, or a lambda (`count.set(lambda v: v + 1)`) and replaces `.set_value`. `rx._x.client_state` remains available with a deprecation warning.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6936.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `rx.client_state` for client-only state. A named var (`rx.client_state("", name="query")`) is global: any component can read and write it, and the backend can `push` and `retrieve` it. An unnamed var is owned by the outermost page or `@rx.memo` that uses it, so each `@rx.memo` instance gets its own value and a page's values reset when it unmounts. Setting a var re-renders only the components that read it. `.set` takes a value, a `FunctionVar`, or a lambda (`count.set(lambda v: v + 1)`) and replaces `.set_value`. `rx._x.client_state` remains available with a deprecation warning.
Loading
Loading