Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
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
20 changes: 20 additions & 0 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ 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.
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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 @@ -33,3 +35,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 @@ -23,6 +23,7 @@ uv run python scripts/check_min_deps.py # validate each
uv run python scripts/check_min_deps.py --check-dev-pins [pkg] # publish gate: fail if pkg (default: all) declares an unpublishable *.dev dependency pin
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 @@ -31,6 +32,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 @@ -58,6 +60,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 @@ -29,7 +28,7 @@ def integration_filter_button(data: dict):
variant="outline",
class_name="flex flex-row items-center "
+ rx.cond(selected_filter.value == data["name"], active_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 @@ -6,7 +6,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 @@ -86,7 +85,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 @@ -101,10 +100,10 @@ def copy_to_markdown(text: rx.Var[str]) -> rx.Component:
variant="ghost",
class_name="justify-start pl-0 text-secondary-11",
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-secondary-4 focus:border-violet-9 focus:border-1 bg-secondary-2 p-[0.5rem_0.75rem] border rounded-xl font-base text-secondary-11 placeholder:text-secondary-9 outline-none focus:outline-none w-full mb-2 pl-10",
),
class_name="relative flex items-center",
Expand Down
116 changes: 116 additions & 0 deletions docs/library/dynamic-rendering/foreach.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,124 @@ def foreach_complex_dict_example():
)
```

## Per-Item State And Event Handlers

Each rendered item gets its own scope, so the item and index are available
anywhere in that item's subtree -- including in event handlers and in
components the compiler splits out on its own:

```python
class TodoState(rx.State):
items: list[str] = ["write docs", "ship it"]

@rx.event
def done(self, item: str, index: int): ...


def todo_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component:
return rx.hstack(
rx.text(item),
rx.button("done", on_click=TodoState.done(item, index)),
)


def todo_list():
return rx.vstack(rx.foreach(TodoState.items, todo_row))
```

Client state works the same way: an unnamed `rx.client_state` var in a
`foreach` body is per item, the way `useState` would be in a React list.

```python
def expandable_row(item: rx.Var[str]) -> rx.Component:
expanded = rx.client_state(False) # one per rendered row
return rx.vstack(
rx.button(item, on_click=expanded.set(lambda prev: ~prev)),
rx.cond(expanded.value, rx.text(f"details for {item}")),
)
```

The default can be the loop item or index, which seeds each row from its own
value:

```python
def counter_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component:
count = rx.client_state(index) # row N starts at N
return rx.hstack(
rx.text(item),
rx.heading(count.value),
rx.button("+", on_click=count.set(lambda prev: prev + 1)),
)
```

A default is a *seed*, read once when the row first claims the slot. It is not a
binding: a later change to the var does not reset a row, which is what keeps a
row from losing what the user typed into it every time the list re-renders.

That has a consequence worth knowing before you reach for it, and it is the same
one `useState(props.value)` has in React. Rows are keyed by position by default
(see below), so replacing the list's contents re-renders the existing rows
instead of mounting new ones -- and a state seeded from the item keeps the *old*
item's seed:

```python
rx.foreach(State.items, lambda item: rx.text(rx.client_state(item).value))
# State.items: ["a", "b", "c"] -> ["d", "e", "f"]
# rx.text(item) renders d, e, f <- follows the data
# rx.client_state(item).value renders a, b, c <- seeded once, per position
```

The item itself always follows the data; only the seeded state lags. Pass `key=`
on the item when you want the state to belong to the item, so replacing the list
unmounts the old rows and the new ones seed themselves:

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

If you want a row's state to track a var while staying editable in between, seed
it and then push updates explicitly with `on_mount=count.set(index)`, or on a
`rx.fragment(key=..., on_mount=...)` to tie the reset to something of your own
choosing.

Loops nest, and each level gets its own scope. A nested body can read an
*enclosing* loop's item and index, as long as it does not reuse their names --
the same rule Python already imposes, since an inner argument of the same name
shadows the outer one:

```python
rx.foreach(
State.rows,
lambda row: rx.foreach(row, lambda cell: rx.text(f"{row[0]}/{cell}")),
)
```

By default each item is keyed by its position in the list. Pass `key=` on the
item to key by identity instead:

```python
rx.foreach(TodoState.items, lambda item: todo_row(item, key=item))
```

The key decides what a row's state belongs to. Under the default positional
keys, changing the list re-renders the existing rows in place rather than
mounting new ones, so anything a row is holding -- a typed-in value, focus, an
in-flight animation, a `rx.client_state` var -- stays with the *position*. Row 3
of the old list keeps its expanded/selected state as row 3 of the new one. Key
by identity and the old rows unmount instead, releasing their state, and a row
that reappears starts fresh.

Neither is the right default for every list, which is why the choice is yours.
Positional keys suit a list whose rows are interchangeable slots, where you want
"row 3 is expanded" to persist as the data flows through. Identity keys suit a
list of distinct things each carrying its own state, where a row's state should
travel with its item across reorders and disappear with it. Identity keys need
the key expression to be unique within the list -- duplicate keys make React
reconcile the wrong rows together.

## API Reference


### `rx.foreach`

```python
Expand Down
Loading
Loading