Skip to content

feat: DH-21756: Add ui.component memoization and selective re-rendering#1296

Open
mofojed wants to merge 35 commits into
deephaven:mainfrom
mofojed:ui-component-memoization
Open

feat: DH-21756: Add ui.component memoization and selective re-rendering#1296
mofojed wants to merge 35 commits into
deephaven:mainfrom
mofojed:ui-component-memoization

Conversation

@mofojed

@mofojed mofojed commented Feb 6, 2026

Copy link
Copy Markdown
Member
  • Added memo parameter to @ui.component to memoize a component, or pass a custom memoization function for checking behaviour
  • Implemented selective re-rendering - only rendering components that have had their state changed
    • This is more in line with how React renders components, and is much more efficient
    • Kind of needed to do this along with memoization; since we already needed to know if a child component was dirty if a parent was memoized

@mofojed mofojed requested a review from mattrunyon February 6, 2026 15:11
@mofojed mofojed self-assigned this Feb 6, 2026
@mofojed mofojed force-pushed the ui-component-memoization branch from f383c9d to e85f1bc Compare February 6, 2026 15:13
)
```

---

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

With this notation we could also write:

memo_parent = ui.memo(parent)

@mofojed mofojed changed the title plan: Add component memoization implementation plan feat: Add ui.memo component memoization Feb 10, 2026
@mofojed mofojed force-pushed the ui-component-memoization branch from 5e1318c to 305fc47 Compare February 10, 2026 21:01

@jnumainville jnumainville left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just gave some comments on the two options.

raise TypeError(
f"@ui.memo can only be used with @ui.component decorated functions. "
f"Got {type(element).__name__} instead."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the fact that we are throwing this error after checking for @ui.component is a point against this option.
A third option would be that @ui.memo creates a ui.component under the hood since it has to be one anyways, but then it would have to duplicate arguments if we add more to ui.component, so more to maintain. I don't love that option either.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yea after playing with @ui.memo more, I think it makes the most sense to just do the @ui.component(memo= option.

Comment on lines +474 to +475
- ❌ Two decorators required (more verbose)
- ❌ Easy to get decorator order wrong (`@ui.component` then `@ui.memo` won't work)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not sure if there is more possible decorators (routers?), but these cons would compound quickly if we did have any others.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If we thought of the decorators as just wrapper components like React, then the order at least makes intuitive sense

But I'm not sure I have a strong opinion on either syntax


**Cons:**

- ❌ Cannot memoize third-party components

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure I understand this con, or at least I don't think it's meaningful? It would be easy enough to take third-party components and put them in your own memoized component without any real problems?
Maybe it's saying you can't do something like ui.memo(external_ui_component, ...) directly, but you can just wrap it in another component, and that isn't substantially more difficult.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, the con is minimal


## Recommendation

**Implement both options**, with Option B (`memo=`) as the primary API and Option A (`@ui.memo`) for advanced use cases.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd say only B is my choice. Easier to maintain, much simpler to use, and I don't think these advanced use cases are really meaningful.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed

@mofojed mofojed changed the title feat: Add ui.memo component memoization feat: Add ui.component memoization and selective re-rendering Feb 13, 2026
@mofojed mofojed force-pushed the ui-component-memoization branch from c56be04 to 0241cc8 Compare February 13, 2026 13:24
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

Comment on lines +261 to +298
items_bad = ["apple", "banana"]

# GOOD: Use use_memo to keep the same reference
items_good = ui.use_memo(lambda: ["apple", "banana"], [])

return ui.flex(
ui.button("Increment", on_press=lambda: set_count(count + 1)),
ui.text(f"Count: {count}"),
item_list(items_good), # Won't re-render unnecessarily
direction="column",
)


app_example = app()
```

### Passing Callback Functions

Lambda functions and inline function definitions create new references each render:

```python
from deephaven import ui


@ui.component(memo=True)
def button_row(on_click):
return ui.button("Click me", on_press=on_click)


@ui.component
def app():
count, set_count = ui.use_state(0)

# BAD: Creates a new function reference every render
# handle_click_bad = lambda: print("clicked")

# GOOD: Use use_callback to memoize the function
handle_click_good = ui.use_callback(lambda: print("clicked"), [])

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.

Inconsistent in presentation, line is left in, vs commented out

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Opted to leave the line in, since it's assigning to a different variable name.

@mofojed mofojed changed the title feat: Add ui.component memoization and selective re-rendering feat: DH-21756: Add ui.component memoization and selective re-rendering Feb 23, 2026
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

@mofojed mofojed force-pushed the ui-component-memoization branch from 17e0d28 to 1ff45d9 Compare March 27, 2026 18:26
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

@mofojed mofojed force-pushed the ui-component-memoization branch from 1ff45d9 to 6188a0b Compare June 11, 2026 15:29
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

2 similar comments
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

@mofojed mofojed force-pushed the ui-component-memoization branch from 2f3ed22 to 79da6df Compare June 12, 2026 14:19
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

mofojed added 4 commits June 12, 2026 13:06
Two options for props-based memoization to skip re-renders:
- Option A: @ui.memo decorator (familiar to React devs)
- Option B: @ui.component(memo=True|compare_fn) parameter (cleaner)

Includes:
- API design and implementation details
- MemoizedFunctionElement and Renderer changes
- Unit tests for both options
- Performance benchmarks
- Comparison and recommendation (implement both)
- Checks props and if they're the same, just return the previously rendered node
- Still need to clean up the `_default_are_props_equal` and how children are handled, I think?
- Also need to add a bunch of unit tests. But it more or less works!

```
from deephaven import ui

def are_props_equal(old_props, new_props):
    print(f"Checking props {old_props} vs {new_props}")
    return old_props == new_props

@ui.component
def foo_component(name):
    value, set_value = ui.use_state(0)
    print(f"foo {name} render")
    return ui.button(f"foo {name} {value}", on_press=lambda: set_value(value+1))

@ui.memo(are_props_equal=are_props_equal)
@ui.component
def memo_foo_component(name):
    value, set_value = ui.use_state(0)
    print(f"memo_foo {name} render")
    return ui.button(f"foo {name} {value}", on_press=lambda: set_value(value+1))

memo_foo = ui.memo()(foo_component)

@ui.component
def bar_component():
    value, set_value = ui.use_state(0)

    return ui.flex(
        foo_component("A"),
        foo_component("B"),
        memo_foo_component("X"),
        memo_foo("Y"),
        ui.button(f"bar {value}", on_press=lambda: set_value(value+1))
    )

mf = memo_foo_component("mf")
b = bar_component()
```

Copilot AI 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.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 4 comments.

Comment thread plugins/ui/src/deephaven/ui/_internal/utils.py
Comment thread plugins/ui/src/deephaven/ui/elements/MemoizedElement.py
Comment thread plugins/ui/src/deephaven/ui/components/component.py Outdated
Comment on lines +542 to +548
def test_shallow_equal(self):
# Identical primitive values are equal
self.assertTrue(shallow_equal(1, 1))
self.assertTrue(shallow_equal("hello", "hello"))
self.assertTrue(shallow_equal(None, None))
self.assertTrue(shallow_equal(True, True))
self.assertTrue(shallow_equal(1.5, 1.5))
Comment on lines +57 to +61
Don't use `memo` when:

- The component's props change on almost every render
- The component is cheap to render
- You're prematurely optimizing without measuring performance

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should maybe add something about a component having side effects. In general side effects that are independent of props should be avoided (and can cause other issues). Probably just worth noting and might be a good pitfall example too. I think I've seen some customer code where they're modifying some global var, so make sure that isn't render specific and is tied to prop/state changes at least

Side effects like that are a code smell/not considered valid in React since they're impure rendering functions

Comment on lines +67 to +71
# Good candidate: renders same static content while parent updates
@ui.component(memo=True)
def expensive_chart(data):
# Imagine this does complex data processing
return ui.text(f"Chart with {len(data)} points")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there a better example that wouldn't be also solved by use_memo? If you had some heavy data processing (like calculate the Nth prime number where N is the prop), I would say just stick it in use_memo.

We use it pretty sparsely in web-client-ui and it's mostly for lots of children or things that end up as heavy DOM paints it seems.

Comment on lines +96 to +98
## Custom Comparison Function

By default, `memo=True` uses shallow equality to compare props. You can provide a custom comparison function by passing it directly to `memo`:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this section should have a similar warning that the React docs has. It mentions it's very rare to need this, be careful of functions, and ensure you don't try to deep check something with a ton of recursion

Comment on lines +104 to +115
def compare_by_id(prev_props, next_props):
"""Only re-render if the 'id' prop changes."""
return prev_props.get("id") == next_props.get("id")


@ui.component(memo=compare_by_id)
def user_card(id, name, last_updated):
return ui.flex(
ui.text(f"User #{id}"),
ui.text(f"Name: {name}"),
ui.text(f"Updated: {last_updated}"),
direction="column",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure how I feel about this example. The React docs say you should still compare every prop. This seems like an example which shows how it works, but not a good reason to do so. At least it should probably say something like "Not recommended. For illustrative purposes only"

The React example is an array of datapoints you can compare, but the array object may change.

Mostly with the increase in LLM usage, don't want it to ingest this page and think this example is what you should do


### Deep Equality Comparison

For props containing nested data structures, you might want deep equality:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Warning about comparing deeply nested structures can get very expensive

Comment on lines +93 to +95
if not is_dirty_render:
# Don't open the context
return _render_list_in_open_context(item, context, is_dirty_render)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What does opening the context do here that we want to avoid it? Also, the function seems like a misnomer consider we don't open the context we pass in

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yea should probably rename it.
So we don't want to open the context in that we're not actually going to render anything at this level, since we want to use the cached results. We don't want it to open the context, think it hasn't rendered anything, then close it, throwing away the previous results (and liveness scopes).
We still may need to render children though, so we still iterate through the results and fetch the child contexts from this context. I'll think about a way to clean this up.

Comment on lines +204 to +207
needs_render = is_dirty_render

if isinstance(element, MemoizedElement):
needs_render = not element.are_props_equal(prev_props)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should the memoized element check also include a is_dirty_render check?

A dirty render is specifically if this element had a state change? Or if it or any of its parents did? Trying to understand which part is selective re-rendering and which is memoization

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We check if the render context.is_dirty below, which detects state changes in the memoized component.

@mattrunyon mattrunyon Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think I'm still a little confused on is_dirty_render vs context.is_dirty here. Either way, we probably don't need to always check are_props_equal if it's a memoized component.

If we know the state changed in the memoized component, checking are_props_equal is just a waste because we will just ignore it. So I think there should be some additional check to avoid running the equality check when we already know it doesn't matter

You could also have is_dirty_render = True, but needs_render = False if are_props_equal = False. Not sure what case that is or if it causes a potential bug case

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

is_dirty_render is just from the parent rendering. So this component may not need to render since it's memoized.

Comment on lines +209 to +214
if not needs_render and not context.is_dirty:
logger.debug("Returning cached element %s", element.name)
rendered_props = _render_dict_in_open_context(
prev_rendered_element_props, context, False
)
return RenderedNode(element.name, rendered_props)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is confusing to me. It doesn't need a render, it's clean, but we seem to render it anyway? Does the cache not store the result of the render already?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added a comment to try and clarify.

props = element.render()
logger.debug("Rendering element %s", element.name)

rendered_element_props = element.render()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I see the comments all mention that render returns "rendered props". Is that true? It seems odd to me b/c I thought the render returned the representation of the element?

Naming things is hard

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes naming things is hard... the render() returns a list of props for the element, and we then just pass those props to the client to actually render the component with those props. Made a comment on Element.render() to help clarify.

realized Document tree for the Element provided.

Key points:
- The Renderer executes Element.render() within a RenderContext to generate the realized Document tree.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This furthers my confusion about calling things "rendered props". This is more what I expect render does

@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

Copilot AI 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.

Pull request overview

Copilot reviewed 28 out of 28 changed files in this pull request and generated 6 comments.

Comment on lines +4 to +9
from .._internal import (
is_iterable,
get_component_qualname,
dict_shallow_equal,
iterable_shallow_equal,
)
Comment on lines +30 to +41
if "children" in prev_props or "children" in next_props:
prev_children = prev_props.get("children")
next_children = next_props.get("children")

# Compare iterable children element-wise using shallow semantics.
if is_iterable(prev_children) and is_iterable(next_children):
if not iterable_shallow_equal(prev_children, next_children):
return False
else:
# For non-iterables, compare by value.
if prev_children != next_children:
return False
Comment on lines +104 to +107
4. With custom comparison function:
@ui.component(memo=lambda prev, next: prev["value"] == next["value"])
def my_component(value, on_click):
return ui.button(str(value), on_press=on_click)
Comment on lines +507 to +520
# Primitives: equal but non-identical values should be equal (compared by value).
# int("1000") is outside CPython's small-int cache, so the two ints are
# distinct objects (different identity) but equal in value.
big1 = int("1000")
big2 = int("1000")
self.assertIsNot(big1, big2)
self.assertTrue(dict_shallow_equal({"count": big1}, {"count": big2}))

# Equal but non-identical strings should also be equal
str1 = "hello world!".upper()
str2 = "hello world!".upper()
self.assertIsNot(str1, str2)
self.assertTrue(dict_shallow_equal({"msg": str1}, {"msg": str2}))

Comment on lines +554 to +558
# Primitives are compared by value
big1 = int("1000")
big2 = int("1000")
self.assertIsNot(big1, big2)
self.assertTrue(iterable_shallow_equal([big1, "x"], [big2, "x"]))
Comment on lines +581 to +592
# Equal but non-identical primitives are equal (compared by value).
# int("1000") is outside CPython's small-int cache, so the two ints are
# distinct objects (different identity) but equal in value.
big1 = int("1000")
big2 = int("1000")
self.assertIsNot(big1, big2)
self.assertTrue(shallow_equal(big1, big2))

str1 = "hello world!".upper()
str2 = "hello world!".upper()
self.assertIsNot(str1, str2)
self.assertTrue(shallow_equal(str1, str2))
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

mofojed added 3 commits June 17, 2026 16:19
…before fetch_only assertions

- Updated test to follow fetch_only semantics by priming child context with initial dirty render before non-dirty render assertions
- Previously the test tried to use fetch_only on a non-existent child context, which is invalid behavior
- Rewrote 'How It Works' section to clarify memoization only applies to props
- Rewrote 'When to Use memo' with emphasis that memo is rare optimization
- Replaced weak example with 'activity_feed' demonstrating large subtree benefit
- Added [!WARNING] box on custom comparison functions with guidance
- Removed problematic deep-equality examples
- Added 'Side Effects During Rendering' pitfall section
- Fixed code examples to avoid markdown parser issues
- Removed obsolete snapshot files that no longer match documentation
- Added docker-compose.docs-snapshots.override.yml to .gitignore for local rootless Docker support
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Comment thread plugins/ui/src/deephaven/ui/elements/MemoizedElement.py
Comment on lines +466 to +470
def mark_clean(self) -> None:
"""
Mark this context as clean. Used for testing to reset the dirty state after a render.
"""
self._is_dirty = False
mofojed and others added 2 commits June 17, 2026 17:31
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

1 similar comment
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Comment thread plugins/ui/src/deephaven/ui/renderer/Renderer.py Outdated
Comment thread plugins/ui/src/deephaven/ui/renderer/Renderer.py Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

@github-actions

Copy link
Copy Markdown

ui docs preview (Available for 14 days)

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.

5 participants