Skip to content

Commit 99b2f79

Browse files
authored
Merge pull request #35 from buiapp/feature/reactive-model
Feature/reactive model
2 parents 766781e + ad4aa8f commit 99b2f79

33 files changed

Lines changed: 2853 additions & 737 deletions

.github/workflows/ci.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,21 @@ jobs:
4545
run: |
4646
uv venv .test-venv --python 3.9
4747
uv pip install --python .test-venv/bin/python dist/*.whl
48-
.test-venv/bin/python -c "import reaktiv"
48+
.test-venv/bin/python -c "import reaktiv"
49+
50+
docs:
51+
runs-on: ubuntu-latest
52+
53+
steps:
54+
- uses: actions/checkout@v4
55+
56+
- name: Setup uv
57+
uses: astral-sh/setup-uv@v5.3.1
58+
with:
59+
python-version: "3.12"
60+
61+
- name: Test interactive examples
62+
run: uv run --group docs python docs/check_examples.py
63+
64+
- name: Build documentation
65+
run: uv run --group docs mkdocs build --strict

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ uv pip install reaktiv
3232
-**Better performance**: Only recalculates what actually changed (fine-grained reactivity)
3333
- 🔄 **Automatic updates**: Dependencies are tracked and updated automatically
3434
- 🎯 **Python-native**: Built for Python's patterns with full async support
35+
- 🧩 **Application-ready models**: Group state, derived values, effects, resources, and cleanup
3536
- 🔒 **Type safe**: Full type hint support with automatic inference
3637
- 🚀 **Lazy evaluation**: Computed values are only calculated when needed
3738
- 💾 **Smart memoization**: Results are cached and only recalculated when dependencies change
@@ -347,6 +348,69 @@ def increment_age(current: int) -> int:
347348
age.update(increment_age) # Type checked!
348349
```
349350

351+
## ReactiveModel For Application State
352+
353+
`ReactiveModel` groups a related signal graph into a reusable Python object.
354+
Each model instance owns independent fields, derived values, effects, and async
355+
resources.
356+
357+
- `field(...)` declares per-instance writable signals.
358+
- `@computed` derives cached values.
359+
- `@linked` creates editable derived state.
360+
- `@effect` runs model-owned side effects.
361+
- `@resource` loads asynchronous data from reactive parameters.
362+
- `dispose()` cleans up all effects and resources owned by the instance.
363+
364+
```python
365+
from reaktiv import ReactiveModel, computed, effect, field
366+
367+
368+
class ShoppingCart(ReactiveModel):
369+
unit_price = field(12.50)
370+
quantity = field(1)
371+
discount = field(0.0)
372+
373+
@computed
374+
def subtotal(self) -> float:
375+
return self.unit_price() * self.quantity()
376+
377+
@computed
378+
def total(self) -> float:
379+
return self.subtotal() * (1 - self.discount())
380+
381+
@effect
382+
def show_total(self) -> None:
383+
print(f"{self.quantity()} item(s): ${self.total():.2f}")
384+
385+
386+
cart = ShoppingCart() # Prints: 1 item(s): $12.50
387+
cart.quantity.set(3) # Prints: 3 item(s): $37.50
388+
cart.discount.set(0.10) # Prints: 3 item(s): $33.75
389+
390+
cart.dispose()
391+
```
392+
393+
Each declared field creates a separate `Signal` for every model instance. Use a
394+
factory for mutable defaults and `field[T]` when the intended type is not clear
395+
from the value:
396+
397+
```python
398+
from typing import Optional
399+
400+
401+
class SearchModel(ReactiveModel):
402+
query = field("")
403+
selected_id = field[Optional[str]](None)
404+
history = field[list[str]](factory=list)
405+
```
406+
407+
Read the [ReactiveModel API guide](https://reaktiv.bui.app/docs/api/reactive-model/)
408+
for fields, typing, linked state, async resources, cleanup, inheritance, and
409+
mixin patterns. Complete examples:
410+
411+
- [`reactive_model_cart.py`](examples/reactive_model_cart.py)
412+
- [`reactive_model_linked_resource.py`](examples/reactive_model_linked_resource.py)
413+
350414
## Why This Pattern?
351415

352416
```mermaid

docs/advanced-features.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This page covers advanced features and techniques in reaktiv for building more s
66

77
By default, reaktiv uses identity comparison (`is`) to determine if a signal's value has changed. For more complex types, you can provide custom equality functions:
88

9-
```python
9+
```pyodide install="reaktiv" height="22" theme="github_light_default,github_dark"
1010
from reaktiv import Signal
1111
1212
# Custom equality for dictionaries
@@ -25,6 +25,8 @@ user.set({"name": "Alice", "age": 30})
2525
2626
# This will trigger updates because the "age" value is different
2727
user.set({"name": "Alice", "age": 31})
28+
29+
print(user())
2830
```
2931

3032
Custom equality functions are especially useful for:
@@ -38,7 +40,7 @@ Custom equality functions are especially useful for:
3840

3941
Effects can register cleanup functions that will run before the next execution or when the effect is disposed:
4042

41-
```python
43+
```pyodide install="reaktiv" assets="no" height="30" theme="github_light_default,github_dark"
4244
from reaktiv import Signal, Effect
4345
4446
counter = Signal(0)
@@ -83,7 +85,7 @@ This pattern is useful for:
8385

8486
The `to_async_iter` utility lets you use signals with `async for` loops:
8587

86-
```python
88+
```pyodide install="reaktiv" assets="no" height="25" theme="github_light_default,github_dark"
8789
import asyncio
8890
from reaktiv import Signal, to_async_iter
8991
@@ -93,7 +95,7 @@ async def main():
9395
# Start a task that increments the counter
9496
async def increment_counter():
9597
for i in range(1, 5):
96-
await asyncio.sleep(1)
98+
await asyncio.sleep(0.05)
9799
counter.set(i)
98100
99101
asyncio.create_task(increment_counter())
@@ -104,7 +106,7 @@ async def main():
104106
if value >= 4:
105107
break
106108
107-
asyncio.run(main())
109+
await main()
108110
```
109111

110112
Output:
@@ -127,7 +129,7 @@ This is useful for:
127129

128130
You can selectively control which signals create dependencies using `untracked`:
129131

130-
```python
132+
```pyodide install="reaktiv" assets="no" height="28" theme="github_light_default,github_dark"
131133
from reaktiv import Signal, Effect, untracked
132134
133135
user_id = Signal(123)
@@ -153,4 +155,7 @@ user_id.set(456)
153155
154156
# This update won't trigger the effect, even though it changes the output
155157
show_details.set(True)
156-
```
158+
159+
user_data.set({"name": "Grace"}) # Now tracked because details are visible
160+
display.dispose()
161+
```

docs/api/compute-signal.md

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,74 @@
11
# Computed Signal API
22

3-
::: reaktiv.Computed
4-
options:
5-
show_source: false
6-
heading_level: 2
7-
show_root_heading: true
8-
show_bases: false
9-
members_order: source
3+
`Computed` and `computed` create computed signals: reactive values that derive
4+
from other signals, recompute lazily, and cache their latest value until a
5+
dependency changes.
6+
7+
`Computed` is kept as the uppercase constructor-style API. `computed` is the
8+
preferred lowercase decorator/factory spelling for new code, especially inside
9+
`ReactiveModel` classes.
10+
11+
## Computed / computed Factory
12+
13+
Create a computed signal from a callable:
14+
15+
```pyodide install="reaktiv" height="12" theme="github_light_default,github_dark"
16+
from reaktiv import Computed, Signal
17+
18+
price = Signal(10)
19+
quantity = Signal(2)
20+
21+
total = Computed(lambda: price() * quantity())
22+
23+
print(total()) # 20
24+
```
25+
26+
Use lowercase decorator syntax for new code:
27+
28+
```pyodide install="reaktiv" assets="no" height="14" theme="github_light_default,github_dark"
29+
from reaktiv import Signal, computed
30+
31+
price = Signal(10)
32+
quantity = Signal(2)
33+
34+
@computed
35+
def total() -> int:
36+
return price() * quantity()
37+
38+
print(total()) # 20
39+
```
40+
41+
When omitting a return annotation, use typed decorator syntax so type checkers
42+
can preserve the returned signal type:
43+
44+
```pyodide install="reaktiv" assets="no" height="12" theme="github_light_default,github_dark"
45+
from reaktiv import Signal, computed
46+
47+
name = Signal("Ada")
48+
49+
@computed[str]
50+
def normalized_name():
51+
return name().strip().lower()
52+
53+
print(normalized_name()) # ada
54+
```
55+
56+
Custom equality can suppress downstream updates when two computed values should
57+
be treated as equivalent:
58+
59+
```pyodide install="reaktiv" assets="no" height="14" theme="github_light_default,github_dark"
60+
from reaktiv import Signal, computed
61+
62+
temperature = Signal(21.04)
63+
64+
@computed[float](equal=lambda left, right: round(left, 1) == round(right, 1))
65+
def rounded_temperature():
66+
return temperature()
67+
68+
print(rounded_temperature())
69+
temperature.set(21.05)
70+
print(rounded_temperature())
71+
```
1072

1173
::: reaktiv.ComputeSignal
1274
options:

0 commit comments

Comments
 (0)