Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
138 changes: 138 additions & 0 deletions docs/library/dynamic-rendering/foreach.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,146 @@ 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}")),
)
```

One exception: the body of an explicit `@rx.memo` component is compiled as a
single component and not split up, so a `foreach` inside it renders its item
inline and an `rx.client_state` var declared in that item function is shared
by every item. To keep per-item state inside a memo, make the item function an
`@rx.memo` component of its own; each rendered instance then owns its state:

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


@rx.memo
def expandable_list(items: rx.Var[list[str]]) -> rx.Component:
return rx.vstack(rx.foreach(items, lambda item: expandable_row(item=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))

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>

`rx.fragment(key=..., on_mount=...)` to tie the reset to something of your own
choosing.
Comment on lines +320 to +323

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.


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))

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.

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))

```

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
19 changes: 14 additions & 5 deletions docs/wrapping-react/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,19 +172,28 @@ def counter():
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:
Each item rendered by `rx.foreach` is its own scope too, so an unnamed var used in a
loop body is per item, and can be seeded from the loop item or index:

```python
@rx.memo
def row(item: rx.Var[str]) -> rx.Component:
def row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component:
expanded = rx.client_state(False) # one per rendered row
count = rx.client_state(index) # row N starts at N
...


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

A default is read once, when the scope first claims the name, so it seeds the state
rather than tracking the var. That means a row seeded from its item keeps the *old*
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

you want it to follow. See
[foreach](/docs/library/dynamic-rendering/foreach) for the worked example.

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

Expand Down
1 change: 1 addition & 0 deletions news/+foreach-scoped-loop-vars.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,62 @@ export const withClientStateScope = (Component) => {
return Wrapped;
};

/**
* Per-render values provided down the tree, chained like the slot scopes.
*
* Distinct from the slot scopes on purpose: these are read-only values that
* change every render (a loop's item and index), so their context identity has
* to be free to change, while a slot scope must stay stable or mounted hooks
* would rebind. Null outside any provider.
*/
export const ScopedValuesContext = createContext(null);

/**
* Provide read-only values to a subtree, keyed by name.
*
* This is how a loop hands its item and index to descendants that compile into
* their own components: they read by name from context instead of closing over
* a variable that only exists inside the loop callback.
*
* It also opens a client state scope, because it marks a component instance the
* same way a memo boundary does -- one rendered item. That makes an unnamed
* client state var used in a loop body per item, which is what a reader of the
* Python expects and what React's `useState` would do.
* @param props The component props.
* @param props.children The children to render.
* @param props.values Mapping of name to value for this subtree.
* @returns The provider element.
*/
export function ScopedValues({ children, values }) {
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>

return createElement(
ScopedValuesContext.Provider,
{ value: chained },
createElement(ClientStateScope, null, children),
);
}

/**
* Read a value provided by an enclosing `ScopedValues`.
*
* Walks outward, so a nested loop's descendants can still reach the outer loop's
* values. Names are generated at compile time, so nested loops never collide.
* @param name The value's name.
* @returns The value, or undefined when nothing provides it.
*/
export function useScopedValue(name) {
const scope = useContext(ScopedValuesContext);
for (let current = scope; current !== null; current = current.parent) {
if (name in current.values) {
return current.values[name];
}
}
return undefined;
}

/**
* Subscribe to a piece of client state.
* @param defaultValue The initial value.
Expand Down
10 changes: 9 additions & 1 deletion packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,15 @@ def render_iterable_tag(component: Any) -> str:
children_rendered = "".join([
_RenderUtils.render(child) for child in component.get("children", [])
])
return f"Array.prototype.map.call({component['iterable_state']} ?? [],(({component['arg_name']},{component['arg_index']})=>({children_rendered})))"
arg = component["arg_name"]
index = component["arg_index"]
# Provide the item and index to the subtree by name, so descendants
# that compile into their own components read them from context.
values = f"{{{arg}:{arg},{index}:{index}}}"
# The provider is the element the map yields, so it carries the key.
key = component["item_key"]
wrapped = f"jsx(ScopedValues,{{key:{key},values:{values}}},{children_rendered})"
return f"Array.prototype.map.call({component['iterable_state']} ?? [],(({arg},{index})=>({wrapped})))"

@staticmethod
def render_match_tag(component: Any) -> str:
Expand Down
19 changes: 15 additions & 4 deletions packages/reflex-base/src/reflex_base/components/memo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2042,7 +2042,7 @@ def component_hash(component: Component, *, recursive: bool) -> str:
return digest


def memo_tag(component: Component) -> str:
def memo_tag(component: Component, *, recursive: bool | None = None) -> str:
"""Compute a stable tag name for the memo wrapping ``component``.

The class qualname is in the tag prefix so distinct classes that render
Expand All @@ -2053,11 +2053,16 @@ def memo_tag(component: Component) -> str:

Args:
component: The component being memoized.
recursive: Whether the memo body carries the whole subtree, so its
descendants' artifacts identify the body too. Defaults to whether
the component's own strategy is a snapshot; a caller that renders
a snapshot of a component whose strategy is not says so here.

Returns:
The stable tag name.
"""
recursive = get_memoization_strategy(component) is MemoizationStrategy.SNAPSHOT
if recursive is None:
recursive = get_memoization_strategy(component) is MemoizationStrategy.SNAPSHOT
prefix, tag = type(component).__qualname__, component.tag or "Comp"
# Memo component classes are already named ``MemoComponent_<tag>``.
if prefix != f"MemoComponent_{tag}":
Expand All @@ -2084,6 +2089,8 @@ def memo_tag(component: Component) -> str:
def create_passthrough_component_memo(
component: Component,
source_module: str | None = None,
*,
snapshot: bool = False,
) -> tuple[
Callable[..., MemoComponent],
MemoComponentDefinition,
Expand All @@ -2104,6 +2111,10 @@ def create_passthrough_component_memo(
component: The component to wrap.
source_module: The user-app Python module that triggered creation of
this memo (typically the page that contained the wrapped subtree).
snapshot: Render the whole component as the memo body, children
included, instead of substituting a ``{children}`` hole. A loop
item is wrapped this way so every hook in its subtree runs inside
the item component, below the scope the loop provides around it.

Returns:
The callable memo wrapper and its component definition.
Expand All @@ -2116,7 +2127,7 @@ def create_passthrough_component_memo(
# body renders the full snapshot rather than a ``{children}``-holed
# template.
render_snapshot = (
get_memoization_strategy(component) is MemoizationStrategy.SNAPSHOT
snapshot or get_memoization_strategy(component) is MemoizationStrategy.SNAPSHOT
)

captured_hole_child: list[Component] = []
Expand Down Expand Up @@ -2158,7 +2169,7 @@ def passthrough(children: Var[Component]) -> Component:
params = _PASSTHROUGH_PARAMS
rest_target_fields: set[str] = set()
preview = _evaluate_component_body(passthrough, params, rest_target_fields)
tag = memo_tag(preview)
tag = memo_tag(preview, recursive=render_snapshot)

passthrough.__name__ = format.to_snake_case(tag)
passthrough.__qualname__ = passthrough.__name__
Expand Down
Loading
Loading