Repository navigation
Scope rx.foreach loop vars per item #7690
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: claude/clientstatevar-context-refactor-jv3pig
Are you sure you want to change the base?
Changes from all commits
32354b9
3fcd132
67e3434
59356ef
a25a9bb
f5f862f
6b49ab1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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)) | ||||||
| ``` | ||||||
|
|
||||||
| 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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Clarify that Prompt for AI agents |
||||||
| `rx.fragment(key=..., on_mount=...)` to tie the reset to something of your own | ||||||
| choosing. | ||||||
|
Comment on lines
+320
to
+323
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
|
|
||||||
| 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)) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This identity-key example raises Prompt for AI agents
Suggested change
|
||||||
| ``` | ||||||
|
|
||||||
| 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 | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Suggested change
|
||||||
| 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")`. | ||||||
|
|
||||||
|
|
||||||
| 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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This release note overstates the per-item state guarantee: Prompt for AI agents
Suggested change
|
||||||
| 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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This release note overstates the guarantee: Prompt for AI agents
Suggested change
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| 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. | ||
|
|
||
There was a problem hiding this comment.
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:
rowis undefined here, and the precedingtodo_rowrequiresindexand does not acceptkey. Putkey=itemon the component returned by the render lambda (or update the row helper) instead.Prompt for AI agents