-
Notifications
You must be signed in to change notification settings - Fork 19
feat: DH-22062: add create_global_state and create_user_state shared state hooks #1324
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: main
Are you sure you want to change the base?
Changes from all commits
0e50026
e1f5353
850eebe
a1377c6
29a4031
1ab5b5e
6023c26
a488c4d
e86e319
aea3fb1
3937a6c
504c242
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 |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| # create_global_state | ||
|
|
||
| `create_global_state` is a factory function that creates a shared state hook. Unlike `use_state`, which creates state local to a single component, the state created by `create_global_state` is shared across all components that call the returned hook. When any component updates the shared state, all other components using the same hook will re-render with the new value. | ||
|
|
||
| Call `create_global_state` at module level (outside of any component) to create a store. Then call the returned hook inside `@ui.component` functions to subscribe to the shared state. | ||
|
|
||
| When all components using a shared store unmount, the state resets to the initial value. | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Basic example | ||
|
|
||
| ```python order=controls,display | ||
| from deephaven import ui | ||
|
|
||
| # Create the shared state at module level | ||
| use_shared_counter = ui.create_global_state(0) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_counter_controls(): | ||
| count, set_count = use_shared_counter() | ||
| return ui.flex( | ||
| ui.button(f"Count: {count}", on_press=lambda: set_count(count + 1)), | ||
| ui.button("Reset", on_press=lambda: set_count(0)), | ||
| ) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_counter_display(): | ||
| count, _ = use_shared_counter() | ||
| return ui.text(f"The shared count is: {count}") | ||
|
|
||
|
|
||
| controls = ui_counter_controls() | ||
| display = ui_counter_display() | ||
| ``` | ||
|
|
||
| In this example, clicking the button in `ui_counter_controls` will update the count displayed in both `ui_counter_controls` and `ui_counter_display`. | ||
|
|
||
| ## Recommendations | ||
|
|
||
| 1. **Create stores at module level**: Call `create_global_state` at module level, not inside a component. The returned hook is then used inside components. | ||
| 2. **Naming convention**: Name the returned hook starting with `use_`, e.g. `use_shared_counter = ui.create_global_state(0)`. This makes it clear that it follows hook rules. | ||
| 3. **Prefer `create_user_state` for user-specific data**: If the state should be independent per user (e.g., user preferences or user-specific selections), use [`create_user_state`](create_user_state.md) instead. | ||
|
|
||
| ### Using updater functions | ||
|
|
||
| Like `use_state`, the setter function supports updater functions for state that depends on the previous value: | ||
|
|
||
| ```python | ||
| from deephaven import ui | ||
|
|
||
| use_shared_counter = ui.create_global_state(0) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_increment_buttons(): | ||
| count, set_count = use_shared_counter() | ||
|
|
||
| def increase_by(n): | ||
| for _ in range(n): | ||
| set_count(lambda prev: prev + 1) | ||
|
|
||
| return ui.flex( | ||
| ui.button("+1", on_press=lambda: increase_by(1)), | ||
| ui.button("+10", on_press=lambda: increase_by(10)), | ||
| ui.text(f"Count: {count}"), | ||
| ) | ||
|
|
||
|
|
||
| buttons = ui_increment_buttons() | ||
| ``` | ||
|
|
||
| When an updater function is passed, it is resolved once using the current store value and the resolved value is broadcast to all subscribers. This ensures all components see the same value regardless of timing. | ||
|
|
||
| ### Shared filter example | ||
|
|
||
| A common use case is sharing filter criteria across multiple views: | ||
|
|
||
| ```python order=slider,filtered,_t | ||
| from deephaven import ui, empty_table | ||
|
|
||
| use_filter_value = ui.create_global_state(50) | ||
|
|
||
| _t = empty_table(1000).update(["x = i", "y = Math.sin(i / 10.0) * 100"]) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_filter_slider(): | ||
| threshold, set_threshold = use_filter_value() | ||
| return ui.slider( | ||
| label=f"Filter threshold: {threshold}", | ||
| value=threshold, | ||
| on_change=set_threshold, | ||
| min_value=0, | ||
| max_value=100, | ||
| ) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_filtered_table(): | ||
| threshold, _ = use_filter_value() | ||
| filtered = ui.use_memo(lambda: _t.where(f"y > {threshold}"), [threshold]) | ||
| return filtered | ||
|
|
||
|
|
||
| slider = ui_filter_slider() | ||
| filtered = ui_filtered_table() | ||
| ``` | ||
|
|
||
| ### Custom hooks | ||
|
|
||
| You can wrap the hook returned by `create_global_state` to build a custom hook with prepackaged behavior: | ||
|
|
||
| ```python order=item_input,item_list | ||
| from deephaven import ui | ||
|
|
||
| _use_items = ui.create_global_state([]) | ||
|
|
||
|
|
||
| def use_items(): | ||
| """A custom hook that adds convenience methods on top of shared state.""" | ||
| items, set_items = _use_items() | ||
|
|
||
| def add(item): | ||
| set_items(lambda prev: prev + [item]) | ||
|
|
||
| def clear(): | ||
| set_items([]) | ||
|
|
||
| return items, add, clear | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_item_input(): | ||
| text, set_text = ui.use_state("") | ||
| items, add, _ = use_items() | ||
|
|
||
| def handle_add(_e): | ||
| if text.strip(): | ||
| add(text.strip()) | ||
| set_text("") | ||
|
|
||
| return ui.flex( | ||
| ui.text_field(label="Add item", value=text, on_change=set_text), | ||
| ui.action_button(f"Add ({len(items)})", on_press=handle_add), | ||
| direction="row", | ||
| gap="size-100", | ||
| align_items="end", | ||
| ) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_item_list(): | ||
| items, _, clear = use_items() | ||
|
Member
Author
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. This is one thing I don't particularly like about Python vs. JS. In JS you could destructure this nicely, e.g. from typing import NamedTuple
class ItemsState(NamedTuple):
items: list
add: callable
clear: callable
def use_items():
items, set_items = _use_items()
...
return ItemsState(items=items, add=add, clear=clear)
# Access by name (order-independent):
state = use_items()
state.items
state.clear()
# Or still unpack positionally if you want:
items, add, clear = use_items()... I kind of like that a bit better, but then you have to define the Or DataClass: from dataclasses import dataclass
@dataclass
class ItemsState:
items: list
add: callable
clear: callableThough you still need to declare Or SimpleNamespace from types import SimpleNamespace
def use_items():
...
return SimpleNamespace(items=items, add=add, clear=clear)
state = use_items()
state.itemsSeems to be the simplest. But doesn't give you any autocomplete/type suggestions. I don't think it's necessary for an example, and AI will be able to figure it out anyways... but should I put that in the example? @dsmmcken @jnumainville any thoughts/comments about that?
Member
Author
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. Basically I think that should just be left up to the user when building their custom hook, and this example is fine for showing you can build a custom hook. AI should be able to figure it out anyways.
Collaborator
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. Yeah I wish there was something like destructuring in Python. I don't think it's worth adding any details on that to an example. I think most people with python are so used to ignoring args with
Member
Author
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. You lose the type hints/inference with itemgetter I think |
||
| return ui.flex( | ||
| ui.list_view( | ||
| *[ui.item(t) for t in items], | ||
| aria_label="Items", | ||
| selection_mode=None, | ||
| ) | ||
| if items | ||
| else ui.text("No items yet"), | ||
| ui.action_button( | ||
| "Clear", on_press=lambda _e: clear(), is_disabled=len(items) == 0 | ||
| ), | ||
| direction="column", | ||
| gap="size-100", | ||
| ) | ||
|
|
||
|
|
||
| item_input = ui_item_input() | ||
| item_list = ui_item_list() | ||
| ``` | ||
|
|
||
| Components call `use_items()` and get back `add` and `clear` functions instead of a raw setter. The button label in the input panel shows the count, which updates when items are cleared from the list panel. | ||
|
|
||
| ## Cleanup behavior | ||
|
|
||
| When all components that subscribe to a shared store unmount (e.g., all panels using the hook are closed), the store is released. This prevents memory leaks from unused state. When a new component later subscribes to the same store, it will be recreated with the initial value. | ||
|
|
||
| If at least one subscriber remains active, the state is preserved. | ||
|
|
||
| ## Thread safety | ||
|
|
||
| `create_global_state` is thread-safe. Multiple components can safely read and update the shared state concurrently. State updates are serialized internally using a lock. | ||
|
|
||
| ## API Reference | ||
|
|
||
| ```{eval-rst} | ||
| .. dhautofunction:: deephaven.ui.create_global_state | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| # create_user_state | ||
|
|
||
| `create_user_state` creates a shared state hook scoped to the current effective user. Like [`create_global_state`](create_global_state.md), the state is shared across all components that call the returned hook — but each user gets their own independent state. When User A updates a value, only User A's components re-render; User B's components remain unaffected. | ||
|
|
||
| Call `create_user_state` at module level (outside of any component) to create a store. Then call the returned hook inside `@ui.component` functions to subscribe. | ||
|
|
||
| When all of a user's components using a shared store unmount, that user's state resets to the initial value. | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Basic example | ||
|
|
||
| ```python order=name_input,greeting | ||
| from deephaven import ui | ||
|
|
||
| # Create user-scoped shared state at module level | ||
| use_user_name = ui.create_user_state("") | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_name_input(): | ||
| name, set_name = use_user_name() | ||
| return ui.text_field(label="Your name", value=name, on_change=set_name) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_greeting(): | ||
| name, _ = use_user_name() | ||
| return ui.text(f"Hello, {name or 'stranger'}!") | ||
|
|
||
|
|
||
| name_input = ui_name_input() | ||
| greeting = ui_greeting() | ||
| ``` | ||
|
|
||
| In this example, each user sees their own name. If User A types "Alice", User B still sees "stranger" until they type their own name. | ||
|
|
||
| ## Recommendations | ||
|
|
||
| 1. **Create stores at module level**: Call `create_user_state` at module level, not inside a component. The returned hook is then used inside components. | ||
| 2. **Naming convention**: Name the returned hook starting with `use_`, e.g. `use_user_preference = ui.create_user_state(default)`. | ||
| 3. **Use for user-specific data**: Preferences, selections, UI state that should differ per user. | ||
| 4. **Use `create_global_state` for shared data**: If you want all users to share the same value (e.g., a global configuration), use [`create_global_state`](create_global_state.md) instead. | ||
|
|
||
| ## Community vs. Enterprise | ||
|
|
||
| On **Deephaven Enterprise**, `create_user_state` uses `deephaven_enterprise.auth_context.get_effective_user()` to identify the current user. Each user gets independent state. | ||
|
|
||
| On **Deephaven Community** (where `deephaven_enterprise` is not installed), all callers share a single anonymous state — effectively behaving the same as `create_global_state`. This allows you to write code that works in both environments without modification. | ||
|
|
||
| ### Per-user selection tracking | ||
|
|
||
| ```python order=item_list,summary | ||
| from deephaven import ui | ||
|
|
||
| use_selected_items = ui.create_user_state([]) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_item_list(): | ||
| selected, set_selected = use_selected_items() | ||
|
|
||
| return ui.list_view( | ||
| ui.item("Alpha"), | ||
| ui.item("Beta"), | ||
| ui.item("Gamma"), | ||
| ui.item("Delta"), | ||
| aria_label="Items", | ||
| selection_mode="MULTIPLE", | ||
| selected_keys=selected, | ||
| on_change=lambda keys: set_selected(list(keys)), | ||
| ) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_selection_summary(): | ||
| selected, _ = use_selected_items() | ||
| if not selected: | ||
| return ui.text("No items selected") | ||
| return ui.text(f"Selected: {', '.join(selected)}") | ||
|
|
||
|
|
||
| item_list = ui_item_list() | ||
| summary = ui_selection_summary() | ||
| ``` | ||
|
|
||
| ### Custom hooks | ||
|
|
||
| You can wrap the hook returned by `create_user_state` to build a custom hook with prepackaged behavior: | ||
|
|
||
| ```python order=message_input,message_list | ||
| from deephaven import ui | ||
|
|
||
| _use_messages = ui.create_user_state([]) | ||
|
|
||
|
|
||
| def use_messages(): | ||
| """A custom hook that adds convenience methods on top of user-scoped state.""" | ||
| messages, set_messages = _use_messages() | ||
|
|
||
| def add(text): | ||
| set_messages(lambda prev: prev + [text]) | ||
|
|
||
| def clear(): | ||
| set_messages([]) | ||
|
|
||
| return messages, add, clear | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_message_input(): | ||
| text, set_text = ui.use_state("") | ||
| _, add, _ = use_messages() | ||
|
|
||
| def handle_add(_e): | ||
| if text.strip(): | ||
| add(text.strip()) | ||
| set_text("") | ||
|
|
||
| return ui.flex( | ||
| ui.text_field(label="Message", value=text, on_change=set_text), | ||
| ui.action_button("Send", on_press=handle_add), | ||
| direction="row", | ||
| gap="size-100", | ||
| align_items="end", | ||
| ) | ||
|
|
||
|
|
||
| @ui.component | ||
| def ui_message_list(): | ||
| messages, _, clear = use_messages() | ||
| return ui.flex( | ||
| ui.list_view( | ||
| *[ui.item(m) for m in messages], | ||
| aria_label="Messages", | ||
| selection_mode=None, | ||
| ) | ||
| if messages | ||
| else ui.text("No messages yet"), | ||
| ui.action_button( | ||
| f"Clear ({len(messages)})", | ||
| on_press=lambda _e: clear(), | ||
| is_disabled=len(messages) == 0, | ||
| ), | ||
| direction="column", | ||
| gap="size-100", | ||
| ) | ||
|
|
||
|
|
||
| message_input = ui_message_input() | ||
| message_list = ui_message_list() | ||
| ``` | ||
|
|
||
| Components call `use_messages()` and get back `add` and `clear` functions instead of a raw setter. Each user's messages are independent — on Enterprise, User A and User B see different lists. | ||
|
|
||
| ## Cleanup behavior | ||
|
|
||
| When all components for a given user unmount, that user's state resets to the initial value and the internal store for that user is cleaned up. This prevents stale state across sessions and avoids memory leaks when users disconnect. | ||
|
|
||
| ## Thread safety | ||
|
|
||
| `create_user_state` is thread-safe. Multiple users' components can safely read and update their state concurrently. | ||
|
|
||
| ## API Reference | ||
|
|
||
| ```{eval-rst} | ||
| .. dhautofunction:: deephaven.ui.create_user_state | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"file":"hooks/create_global_state.md","objects":{"controls":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Count: 0"}},{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"Reset"}}]}}},"__dhElemName":"__main__.ui_counter_controls"},"state":"{\"state\": {\"0\": 0}}"}},"display":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["The shared count is: 0"],"slot":"text"}}},"__dhElemName":"__main__.ui_counter_display"},"state":"{\"state\": {\"0\": 0}}"}}}} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"file":"hooks/create_user_state.md","objects":{"name_input":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.TextField","props":{"value":"","label":"Your name","labelPosition":"top","onChange":{"__dhCbid":"cb0"}}}},"__dhElemName":"__main__.ui_name_input"},"state":"{\"state\": {\"0\": \"\"}}"}},"greeting":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Hello, stranger!"],"slot":"text"}}},"__dhElemName":"__main__.ui_greeting"},"state":"{\"state\": {\"0\": \"\"}}"}}}} |
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.
Is it worth some sort of caution or emphasis using this? Seems like shared state updates should be used with caution lest you hose every user.