Skip to content

Commit bf6bb57

Browse files
committed
feat(hooks,reconciler)!: defer effects; add batching and use_reducer
1 parent 094d997 commit bf6bb57

File tree

14 files changed

+827
-53
lines changed

14 files changed

+827
-53
lines changed

docs/api/pythonnative.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313

1414
Each returns an `Element` descriptor. Visual and layout properties are passed via `style={...}`. See the Component Property Reference for full details.
1515

16+
### ErrorBoundary
17+
18+
`pythonnative.ErrorBoundary(child, fallback=...)` — catches render errors in *child* and displays *fallback* instead. *fallback* may be an `Element` or a callable that receives the exception and returns an `Element`.
19+
1620
### Element
1721

1822
`pythonnative.Element` — the descriptor type returned by element functions. You generally don't create these directly.
@@ -23,7 +27,8 @@ Function component primitives:
2327

2428
- `pythonnative.component` — decorator to create a function component
2529
- `pythonnative.use_state(initial)` — local component state
26-
- `pythonnative.use_effect(effect, deps)` — side effects
30+
- `pythonnative.use_reducer(reducer, initial_state)` — reducer-based state management; returns `(state, dispatch)`
31+
- `pythonnative.use_effect(effect, deps)` — side effects, run after native commit
2732
- `pythonnative.use_navigation()` — navigation handle (push/pop/get_args)
2833
- `pythonnative.use_memo(factory, deps)` — memoised values
2934
- `pythonnative.use_callback(fn, deps)` — stable function references
@@ -32,6 +37,10 @@ Function component primitives:
3237
- `pythonnative.create_context(default)` — create a new context
3338
- `pythonnative.Provider(context, value, child)` — provide a context value
3439

40+
### Batching
41+
42+
- `pythonnative.batch_updates()` — context manager that batches multiple state updates into a single re-render
43+
3544
### Styling
3645

3746
- `pythonnative.StyleSheet` — utility for creating and composing style dicts
@@ -53,7 +62,7 @@ Function component primitives:
5362

5463
## Reconciler
5564

56-
`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, and context providers. Used internally by `create_page`.
65+
`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, context providers, and error boundaries. Effects are flushed after each mount/reconcile pass. Used internally by `create_page`.
5766

5867
## Hot reload
5968

docs/concepts/architecture.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,35 @@ PythonNative combines **direct native bindings** with a **declarative reconciler
55
## High-level model
66

77
1. **Declarative element tree:** Your `@pn.component` function returns a tree of `Element` descriptors (similar to React elements / virtual DOM nodes).
8-
2. **Function components and hooks:** All UI is built with `@pn.component` functions using `use_state`, `use_effect`, `use_navigation`, etc. — inspired by React hooks but designed for Python.
8+
2. **Function components and hooks:** All UI is built with `@pn.component` functions using `use_state`, `use_reducer`, `use_effect`, `use_navigation`, etc. — inspired by React hooks but designed for Python.
99
3. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by hook state changes), it diffs the new tree against the old one and applies the minimal set of native mutations.
10-
4. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content.
11-
5. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls:
10+
4. **Post-render effects:** Effects queued via `use_effect` are flushed **after** the reconciler commits native mutations, matching React semantics. This guarantees that effect callbacks interact with the committed native tree.
11+
5. **State batching:** Multiple state updates triggered during a render pass (e.g. from effects) are automatically batched into a single re-render. Explicit batching is available via `pn.batch_updates()`.
12+
6. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content.
13+
7. **Error boundaries:** `pn.ErrorBoundary` catches render errors in child subtrees and displays fallback UI, preventing a single component failure from crashing the entire page.
14+
8. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls:
1215
- **iOS:** rubicon-objc exposes Objective-C/Swift classes (`UILabel`, `UIButton`, `UIStackView`, etc.).
1316
- **Android:** Chaquopy exposes Java classes (`android.widget.TextView`, `android.widget.Button`, etc.) via the JNI bridge.
14-
6. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It calls `create_page()` internally to bootstrap your Python component, and the reconciler drives the UI from there.
17+
9. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It calls `create_page()` internally to bootstrap your Python component, and the reconciler drives the UI from there.
1518

1619
## How it works
1720

1821
```
19-
@pn.component fn → Element tree → Reconciler → Native views
22+
@pn.component fn → Element tree → Reconciler → Native views → Flush effects
2023
21-
Hook set_state() → re-render → diff → patch native views
24+
Hook set_state() → schedule render → diff → patch native views → Flush effects
25+
(batched)
2226
```
2327

2428
The reconciler uses **key-based diffing** (matching children by key first, then by position). When a child with the same key/type is found, its props are updated in-place on the native view. When the type changes, the old native view is destroyed and a new one is created.
2529

30+
### Render lifecycle
31+
32+
1. **Render phase:** Component functions execute. Hooks record state reads, queue effects, and register memos. No native mutations happen yet.
33+
2. **Commit phase:** The reconciler applies the diff to native views — creating, updating, and removing views as needed.
34+
3. **Effect phase:** Pending effects are flushed in depth-first order (children before parents). Cleanup functions from the previous render run before new effect callbacks.
35+
4. **Drain phase:** If effects set state, a new render pass is automatically triggered and the cycle repeats (up to a safety limit to prevent infinite loops).
36+
2637
## Component model
2738

2839
PythonNative uses a single component model: **function components** decorated with `@pn.component`.
@@ -40,7 +51,7 @@ def Counter(initial: int = 0):
4051

4152
Each component is a Python function that:
4253
- Accepts props as keyword arguments
43-
- Uses hooks for state (`use_state`), side effects (`use_effect`), navigation (`use_navigation`), and more
54+
- Uses hooks for state (`use_state`, `use_reducer`), side effects (`use_effect`), navigation (`use_navigation`), and more
4455
- Returns an `Element` tree describing the UI
4556
- Each call site creates an independent instance with its own hook state
4657

docs/concepts/components.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ pn.Column(
5252

5353
- `Modal(*children, visible, on_dismiss, title)` — modal dialog
5454

55+
**Error handling:**
56+
57+
- `ErrorBoundary(child, fallback)` — catches render errors in child and displays fallback
58+
5559
**Lists:**
5660

5761
- `FlatList(data, render_item, key_extractor, separator_height)` — scrollable data list
@@ -164,7 +168,8 @@ Changing one `Counter` doesn't affect the other — each has its own hook state.
164168
### Available hooks
165169

166170
- `use_state(initial)` — local component state; returns `(value, setter)`
167-
- `use_effect(effect, deps)` — side effects (timers, API calls, subscriptions)
171+
- `use_reducer(reducer, initial_state)` — reducer-based state; returns `(state, dispatch)`
172+
- `use_effect(effect, deps)` — side effects, run after native commit (timers, API calls, subscriptions)
168173
- `use_memo(factory, deps)` — memoised computed values
169174
- `use_callback(fn, deps)` — stable function references
170175
- `use_ref(initial)` — mutable ref that persists across renders

docs/concepts/hooks.md

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,40 @@ If the initial value is expensive to compute, pass a callable:
5858
count, set_count = pn.use_state(lambda: compute_default())
5959
```
6060

61+
### use_reducer
62+
63+
For complex state logic, `use_reducer` lets you manage state transitions through a reducer function — similar to React's `useReducer`:
64+
65+
```python
66+
def reducer(state, action):
67+
if action == "increment":
68+
return state + 1
69+
if action == "decrement":
70+
return state - 1
71+
if action == "reset":
72+
return 0
73+
return state
74+
75+
@pn.component
76+
def Counter():
77+
count, dispatch = pn.use_reducer(reducer, 0)
78+
79+
return pn.Column(
80+
pn.Text(f"Count: {count}"),
81+
pn.Row(
82+
pn.Button("-", on_click=lambda: dispatch("decrement")),
83+
pn.Button("+", on_click=lambda: dispatch("increment")),
84+
pn.Button("Reset", on_click=lambda: dispatch("reset")),
85+
style={"spacing": 8},
86+
),
87+
)
88+
```
89+
90+
The reducer receives the current state and an action, and returns the new state. Actions can be any value (strings, dicts, etc.). The component only re-renders when the reducer returns a different state.
91+
6192
### use_effect
6293

63-
Run side effects after render. The effect function may return a cleanup callable.
94+
Run side effects **after** the native view tree is committed. The effect function may return a cleanup callable.
6495

6596
```python
6697
@pn.component
@@ -78,6 +109,8 @@ def Timer():
78109
return pn.Text(f"Elapsed: {seconds}s")
79110
```
80111

112+
Effects are **deferred** — they are queued during the render phase and executed after the reconciler finishes committing native view mutations. This means effect callbacks can safely measure layout or interact with the committed native tree.
113+
81114
Dependency control:
82115

83116
- `pn.use_effect(fn, None)` — run on every render
@@ -169,6 +202,45 @@ def UserProfile():
169202
return pn.Text(f"Welcome, {user['name']}")
170203
```
171204

205+
## Batching state updates
206+
207+
By default, each state setter call triggers a re-render. When you need to update multiple pieces of state at once, use `pn.batch_updates()` to coalesce them into a single render pass:
208+
209+
```python
210+
@pn.component
211+
def Form():
212+
name, set_name = pn.use_state("")
213+
email, set_email = pn.use_state("")
214+
215+
def on_submit():
216+
with pn.batch_updates():
217+
set_name("Alice")
218+
set_email("alice@example.com")
219+
# single re-render here
220+
221+
return pn.Column(
222+
pn.Text(f"{name} <{email}>"),
223+
pn.Button("Fill", on_click=on_submit),
224+
)
225+
```
226+
227+
State updates triggered by effects during a render pass are automatically batched — the framework drains any pending re-renders after effect flushing completes, so you don't need `batch_updates()` inside effects.
228+
229+
## Error boundaries
230+
231+
Wrap risky components in `pn.ErrorBoundary` to catch render errors and display a fallback UI:
232+
233+
```python
234+
@pn.component
235+
def App():
236+
return pn.ErrorBoundary(
237+
MyRiskyComponent(),
238+
fallback=lambda err: pn.Text(f"Something went wrong: {err}"),
239+
)
240+
```
241+
242+
Without an error boundary, an exception during rendering crashes the entire page. Error boundaries catch errors during both initial mount and subsequent reconciliation.
243+
172244
## Custom hooks
173245

174246
Extract reusable stateful logic into plain functions:

src/pythonnative/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def App():
2020
ActivityIndicator,
2121
Button,
2222
Column,
23+
ErrorBoundary,
2324
FlatList,
2425
Image,
2526
Modal,
@@ -39,13 +40,15 @@ def App():
3940
from .element import Element
4041
from .hooks import (
4142
Provider,
43+
batch_updates,
4244
component,
4345
create_context,
4446
use_callback,
4547
use_context,
4648
use_effect,
4749
use_memo,
4850
use_navigation,
51+
use_reducer,
4952
use_ref,
5053
use_state,
5154
)
@@ -57,6 +60,7 @@ def App():
5760
"ActivityIndicator",
5861
"Button",
5962
"Column",
63+
"ErrorBoundary",
6064
"FlatList",
6165
"Image",
6266
"Modal",
@@ -76,13 +80,15 @@ def App():
7680
"Element",
7781
"create_page",
7882
# Hooks
83+
"batch_updates",
7984
"component",
8085
"create_context",
8186
"use_callback",
8287
"use_context",
8388
"use_effect",
8489
"use_memo",
8590
"use_navigation",
91+
"use_reducer",
8692
"use_ref",
8793
"use_state",
8894
"Provider",

src/pythonnative/components.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,29 @@ def Pressable(
357357
return Element("Pressable", props, children, key=key)
358358

359359

360+
def ErrorBoundary(
361+
child: Optional[Element] = None,
362+
*,
363+
fallback: Optional[Any] = None,
364+
key: Optional[str] = None,
365+
) -> Element:
366+
"""Catch render errors in *child* and display *fallback* instead.
367+
368+
*fallback* may be an ``Element`` or a callable that receives the
369+
exception and returns an ``Element``::
370+
371+
pn.ErrorBoundary(
372+
MyRiskyComponent(),
373+
fallback=lambda err: pn.Text(f"Error: {err}"),
374+
)
375+
"""
376+
props: Dict[str, Any] = {}
377+
if fallback is not None:
378+
props["__fallback__"] = fallback
379+
children = [child] if child is not None else []
380+
return Element("__ErrorBoundary__", props, children, key=key)
381+
382+
360383
def FlatList(
361384
*,
362385
data: Optional[List[Any]] = None,

0 commit comments

Comments
 (0)