Skip to content

Scope rx.foreach loop vars per item - #7690

Open
masenf wants to merge 7 commits into
claude/clientstatevar-context-refactor-jv3pigfrom
claude/foreach-scoped-loop-vars
Open

masenf wants to merge 7 commits into
claude/clientstatevar-context-refactor-jv3pigfrom
claude/foreach-scoped-loop-vars

Conversation

@masenf

@masenf masenf commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • This change requires a documentation update

Description

Follow-up to #6936, which this PR is stacked on. It carries the rx.foreach loop-var scoping that was split out of that PR.

What changes for users

  • The item and index of an rx.foreach are available anywhere in the item's subtree, including event handlers the compiler hoists into useCallback and descendants it compiles into their own memo modules. Fixes [REF-2745] rx.form handle_submit lambda cannot reference foreach vars #3210.
  • Each loop item renders through a component of its own, so an unnamed rx.client_state var declared in the item function is per item, and it can be seeded from the item or index.
  • An explicit key= on the item is used to key the list; otherwise it is keyed by position as before.
  • Inside an explicit @rx.memo body a loop is not split up, so per-item client state there still uses an @rx.memo item function. The foreach docs say so.

Implementation

  • IterTag builds loop vars with scoped_loop_var, which reads the value from an enclosing ScopedValues provider through useScopedValue; the map callback's parameter shadows the hook inside the loop body itself.
  • Foreach renders each item inside ScopedValues, which also opens a client state scope, and no longer seals its subtree from auto-memoization.
  • MemoizeStatefulPlugin wraps each loop item in a snapshot memo component after a sealed sub-walk over the item, and a forced snapshot body is hashed by its whole subtree.

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally? (unit, JS, Playwright; ruff and pyright clean)

Generated by Claude Code

View guided diff Turn on auto-fix

Each rendered item publishes its value and index through a ScopedValues
provider, and loop vars read them from that scope, so event handlers
hoisted into useCallback and descendants compiled into their own memo
modules can use them. Every item renders through a component of its
own, so an unnamed rx.client_state var in the item function is per
item, and an explicit key on the item keys the list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
@masenf
masenf requested review from a team and Alek99 as code owners October 28, 2026 18:20
@greptile-apps

greptile-apps Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge, with two non-blocking documentation corrections still outstanding.

Findings

  1. P2 Keyed example fails to build ▶
  2. P2 Mount reset leaves old values ▶

Summary

This PR gives each rx.foreach item its own scope and makes loop values available to event handlers and separately compiled descendants.

  • Loop items and indexes reach handlers and nested components.
  • Each loop item gets its own client state and can use its own key.

Reviews (5) · Last reviewed commit: "Refresh the memo.pyi hash for this branc..." · Reviewed by Greptile

item to key by identity instead:

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Keyed example fails to build

The example calls todo_row(item, key=item), but todo_row above requires index and does not accept key. Copying it raises TypeError while building the page. Use a callback that supplies both arguments, and put key=item on the component returned by todo_row.

Comment on lines +320 to +323
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mount reset leaves old values

on_mount=count.set(index) does not make the value follow later changes to index. With positional keys, the existing row stays mounted, so this event never runs again. Show an update that runs when the source changes, or explain that on_mount only resets the value when the row mounts. The same advice in docs/wrapping-react/overview.md needs the same correction.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

8 issues found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/wrapping-react/overview.md">

<violation number="1" location="docs/wrapping-react/overview.md:193">
P2: `on_mount=count.set(index)` does not make the state follow later item changes: `on_mount` runs only once per mounted row, while positional `rx.foreach` rows stay mounted. Recommend an event/update tied to the changed item, or key the row so replacement remounts it.</violation>
</file>

<file name="news/+foreach-scoped-loop-vars.bugfix.md">

<violation number="1" location="news/+foreach-scoped-loop-vars.bugfix.md:1">
P2: This release note overstates the per-item state guarantee: `foreach` bodies inside an explicit `@rx.memo` are not split, so an unnamed client-state var in that item function remains shared. Qualify the note with this exception or point users to the required memoized item function.</violation>
</file>

<file name="tests/units/compiler/test_memoize_plugin.py">

<violation number="1" location="tests/units/compiler/test_memoize_plugin.py:2969">
P2: `handler is not foreach_block` compares object identity, not block contents; each separate `split()` call creates fresh strings, so this passes even if both blocks are emitted by one export. Compare the blocks by value or assert their export names differ.</violation>
</file>

<file name="docs/library/dynamic-rendering/foreach.md">

<violation number="1" location="docs/library/dynamic-rendering/foreach.md:317">
P2: This example cannot run: `row` is undefined here, and the preceding `todo_row` requires `index` and does not accept `key`. Put `key=item` on the component returned by the render lambda (or update the row helper) instead.</violation>

<violation number="2" location="docs/library/dynamic-rendering/foreach.md:321">
P2: Clarify that `on_mount=count.set(index)` only resets the value when the row mounts; it does not follow later index changes under positional keys. Make the same correction in `docs/wrapping-react/overview.md`.</violation>

<violation number="3" location="docs/library/dynamic-rendering/foreach.md:341">
P2: This identity-key example raises `TypeError` because `todo_row` does not accept `key` and the callback does not provide its required `index`. Put the key on a component returned by the callback, for example `rx.fragment(todo_row(item, index), key=item)`; apply the same pattern to the earlier `row(item, key=item)` example.</violation>
</file>

<file name="packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js">

<violation number="1" location="packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js:313">
P2: `ScopedValues` creates a new context value on every render, so memoized item consumers re-render whenever the loop remaps, even when their item and index are unchanged. Preserve the chained value when `parent` and each provided entry are `Object.is`-equal.</violation>
</file>

<file name="packages/reflex-base/news/+foreach-scoped-loop-vars.bugfix.md">

<violation number="1" location="packages/reflex-base/news/+foreach-scoped-loop-vars.bugfix.md:1">
P2: This release note overstates the guarantee: `foreach` inside an explicit `@rx.memo` body still shares unnamed client state across items. Mention this exception so users do not rely on per-item state in the unsupported case.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

seed when the list's contents change, because `rx.foreach` keys rows by position and
a row that does not unmount never re-claims its slot. The item itself still follows
the data -- only the seeded state lags. Pass `key=` on the item to tie a row's state
to the item instead, or set the value explicitly (`on_mount=count.set(index)`) when

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: on_mount=count.set(index) does not make the state follow later item changes: on_mount runs only once per mounted row, while positional rx.foreach rows stay mounted. Recommend an event/update tied to the changed item, or key the row so replacement remounts it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/wrapping-react/overview.md, line 193:

<comment>`on_mount=count.set(index)` does not make the state follow later item changes: `on_mount` runs only once per mounted row, while positional `rx.foreach` rows stay mounted. Recommend an event/update tied to the changed item, or key the row so replacement remounts it.</comment>

<file context>
@@ -172,19 +172,28 @@ def counter():
+seed when the list's contents change, because `rx.foreach` keys rows by position and
+a row that does not unmount never re-claims its slot. The item itself still follows
+the data -- only the seeded state lags. Pass `key=` on the item to tie a row's state
+to the item instead, or set the value explicitly (`on_mount=count.set(index)`) when
+you want it to follow. See
+[foreach](/docs/library/dynamic-rendering/foreach) for the worked example.
</file context>
Suggested change
to the item instead, or set the value explicitly (`on_mount=count.set(index)`) when
to the item instead, or update it from an event when

@@ -0,0 +1 @@
`rx.foreach` item and index values are now available to event handlers and memoized components rendered inside the loop, and each item renders as a component of its own, so an unnamed `rx.client_state` var declared in the item function is per item.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This release note overstates the per-item state guarantee: foreach bodies inside an explicit @rx.memo are not split, so an unnamed client-state var in that item function remains shared. Qualify the note with this exception or point users to the required memoized item function.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At news/+foreach-scoped-loop-vars.bugfix.md, line 1:

<comment>This release note overstates the per-item state guarantee: `foreach` bodies inside an explicit `@rx.memo` are not split, so an unnamed client-state var in that item function remains shared. Qualify the note with this exception or point users to the required memoized item function.</comment>

<file context>
@@ -0,0 +1 @@
+`rx.foreach` item and index values are now available to event handlers and memoized components rendered inside the loop, and each item renders as a component of its own, so an unnamed `rx.client_state` var declared in the item function is per item.
</file context>
Suggested change
`rx.foreach` item and index values are now available to event handlers and memoized components rendered inside the loop, and each item renders as a component of its own, so an unnamed `rx.client_state` var declared in the item function is per item.
`rx.foreach` item and index values are now available to event handlers and memoized components rendered inside the loop. Each item function renders as its own component, so an unnamed `rx.client_state` var declared there is per item; loops inside an explicit `@rx.memo` still require a memoized item function for per-item state.

# The read has to happen below the provider, which means in a module of its
# own: hooks are hoisted to the top of whichever component they land in, so
# a read in the module that *renders* the provider would sit above it.
assert handler is not foreach_block, "the handler must be its own memo module"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: handler is not foreach_block compares object identity, not block contents; each separate split() call creates fresh strings, so this passes even if both blocks are emitted by one export. Compare the blocks by value or assert their export names differ.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/units/compiler/test_memoize_plugin.py, line 2969:

<comment>`handler is not foreach_block` compares object identity, not block contents; each separate `split()` call creates fresh strings, so this passes even if both blocks are emitted by one export. Compare the blocks by value or assert their export names differ.</comment>

<file context>
@@ -2815,6 +2918,135 @@ def page() -> Component:
+    # The read has to happen below the provider, which means in a module of its
+    # own: hooks are hoisted to the top of whichever component they land in, so
+    # a read in the module that *renders* the provider would sit above it.
+    assert handler is not foreach_block, "the handler must be its own memo module"
+    # Inside the loop body the callback parameter of the same name shadows any
+    # hoisted read, so inline uses see the real per-item value.
</file context>
Suggested change
assert handler is not foreach_block, "the handler must be its own memo module"
assert handler != foreach_block, "the handler must be its own memo module"

item to key by identity instead:

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This identity-key example raises TypeError because todo_row does not accept key and the callback does not provide its required index. Put the key on a component returned by the callback, for example rx.fragment(todo_row(item, index), key=item); apply the same pattern to the earlier row(item, key=item) example.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/library/dynamic-rendering/foreach.md, line 341:

<comment>This identity-key example raises `TypeError` because `todo_row` does not accept `key` and the callback does not provide its required `index`. Put the key on a component returned by the callback, for example `rx.fragment(todo_row(item, index), key=item)`; apply the same pattern to the earlier `row(item, key=item)` example.</comment>

<file context>
@@ -220,8 +220,146 @@ def foreach_complex_dict_example():
+item to key by identity instead:
+
+```python
+rx.foreach(TodoState.items, lambda item: todo_row(item, key=item))
+```
+
</file context>
Suggested change
rx.foreach(TodoState.items, lambda item: todo_row(item, key=item))
rx.foreach(TodoState.items, lambda item, index: rx.fragment(todo_row(item, index), key=item))

const parent = useContext(ScopedValuesContext);
// A fresh object each render is correct here -- the values themselves change
// per render, and nothing subscribes to them.
const chained = { parent, values };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: ScopedValues creates a new context value on every render, so memoized item consumers re-render whenever the loop remaps, even when their item and index are unchanged. Preserve the chained value when parent and each provided entry are Object.is-equal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js, line 313:

<comment>`ScopedValues` creates a new context value on every render, so memoized item consumers re-render whenever the loop remaps, even when their item and index are unchanged. Preserve the chained value when `parent` and each provided entry are `Object.is`-equal.</comment>

<file context>
@@ -280,6 +280,62 @@ export const withClientStateScope = (Component) => {
+  const parent = useContext(ScopedValuesContext);
+  // A fresh object each render is correct here -- the values themselves change
+  // per render, and nothing subscribes to them.
+  const chained = { parent, values };
+  return createElement(
+    ScopedValuesContext.Provider,
</file context>

@@ -0,0 +1 @@
`rx.foreach` item and index values are now available to event handlers and memoized components rendered inside the loop, and each item renders as a component of its own, so an unnamed `rx.client_state` var declared in the item function is per item.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This release note overstates the guarantee: foreach inside an explicit @rx.memo body still shares unnamed client state across items. Mention this exception so users do not rely on per-item state in the unsupported case.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/reflex-base/news/+foreach-scoped-loop-vars.bugfix.md, line 1:

<comment>This release note overstates the guarantee: `foreach` inside an explicit `@rx.memo` body still shares unnamed client state across items. Mention this exception so users do not rely on per-item state in the unsupported case.</comment>

<file context>
@@ -0,0 +1 @@
+`rx.foreach` item and index values are now available to event handlers and memoized components rendered inside the loop, and each item renders as a component of its own, so an unnamed `rx.client_state` var declared in the item function is per item.
</file context>
Suggested change
`rx.foreach` item and index values are now available to event handlers and memoized components rendered inside the loop, and each item renders as a component of its own, so an unnamed `rx.client_state` var declared in the item function is per item.
`rx.foreach` item and index values are now available to event handlers and memoized components rendered inside the loop. Automatically split items render as separate components, so an unnamed `rx.client_state` var declared in the item function is per item; loops inside an explicit `@rx.memo` still require a separately memoized item function for per-item state.

unmounts the old rows and the new ones seed themselves:

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This example cannot run: row is undefined here, and the preceding todo_row requires index and does not accept key. Put key=item on the component returned by the render lambda (or update the row helper) instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/library/dynamic-rendering/foreach.md, line 317:

<comment>This example cannot run: `row` is undefined here, and the preceding `todo_row` requires `index` and does not accept `key`. Put `key=item` on the component returned by the render lambda (or update the row helper) instead.</comment>

<file context>
@@ -220,8 +220,146 @@ def foreach_complex_dict_example():
+unmounts the old rows and the new ones seed themselves:
+
+```python
+rx.foreach(State.items, lambda item: row(item, key=item))
+```
+
</file context>
Suggested change
rx.foreach(State.items, lambda item: row(item, key=item))
rx.foreach(State.items, lambda item: rx.text(rx.client_state(item).value, 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Clarify that on_mount=count.set(index) only resets the value when the row mounts; it does not follow later index changes under positional keys. Make the same correction in docs/wrapping-react/overview.md.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/library/dynamic-rendering/foreach.md, line 321:

<comment>Clarify that `on_mount=count.set(index)` only resets the value when the row mounts; it does not follow later index changes under positional keys. Make the same correction in `docs/wrapping-react/overview.md`.</comment>

<file context>
@@ -220,8 +220,146 @@ def foreach_complex_dict_example():
+```
+
+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.
</file context>

@codspeed

codspeed Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 11.33%

2 warnings

⚠️ 20 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 5 benchmarks with a significant change ran on different hardware

Their base and head results were measured on machines with a different CPU, core count or memory, so the changes might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

❌ 5 regressed benchmarks
✅ 143 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_compile_all_artifacts[_stateful_page] 38 ms 45.1 ms -15.76%
❌ test_compile_page_full_context[_stateful_page] 45.9 ms 53.7 ms -14.46%
❌ test_compile_page[_stateful_page] 43.4 ms 50.5 ms -14.16%
❌ test_evaluate_page[_stateful_page] 7.4 ms 7.9 ms -6.03%
❌ test_evaluate_page_with_hooks[_stateful_page] 8 ms 8.4 ms -5.67%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/foreach-scoped-loop-vars (f5f862f) with claude/clientstatevar-context-refactor-jv3pig (f6beab9)

Open in CodSpeed

…ude/foreach-scoped-loop-vars

# Conflicts:
#	tests/js/client_state.test.js
The merge commit kept the conflict markers in tests/js/client_state.test.js.
Keep the scoped-value lifetime tests and drop the registry prop the provider
no longer takes.

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

This branch has not been deployed

No deployments
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