Reactivity
Pulse has a powerful reactivity system that automatically tracks dependencies and updates your UI when data changes. This section covers computed values and effects, two key features that make working with state intuitive and efficient.
Computed values
A computed value is a cached calculation that automatically updates when its dependencies change. Think of it like a spreadsheet cell that contains a formula: it recalculates only when the cells it references change.
Defining computed values
Use the @ps.computed decorator on a method in your state class:
class TodoState(ps.State):
todos: list[Todo] = []
filter: str = "all" # "all", "active", or "completed"
@ps.computed
def filtered_todos(self) -> list[Todo]:
if self.filter == "active":
return [t for t in self.todos if not t.done]
if self.filter == "completed":
return [t for t in self.todos if t.done]
return self.todos
@ps.computed
def remaining_count(self) -> int:
return len([t for t in self.todos if not t.done])Now you can use these computed values just like regular properties:
@ps.component
def TodoApp():
with ps.init():
state = TodoState()
return ps.div()[
ps.p(f"{state.remaining_count} items left"),
ps.ul()[
ps.For(state.filtered_todos, lambda t: ps.li(t.text, key=t.id))
],
]How computed values work
When you first access a computed value, Pulse:
- Runs the function
- Tracks which state properties were accessed during execution
- Caches the result
On subsequent accesses:
- If no dependencies changed, Pulse returns the cached value instantly
- If any dependency changed, Pulse re-runs the function and updates the cache
This means you can call state.filtered_todos multiple times in your render without worrying about performance. The filtering logic only runs when todos or filter actually change.
Computed values outside state
You can also create computed values that depend on multiple states using ps.Computed:
def setup_counters():
counter1 = CounterState()
counter2 = CounterState()
@ps.Computed
def total():
return counter1.count + counter2.count
return counter1, counter2, total
@ps.component
def CounterPair():
counter1, counter2, total = ps.setup(setup_counters)
return ps.div()[
ps.p(f"Counter 1: {counter1.count}"),
ps.p(f"Counter 2: {counter2.count}"),
ps.p(f"Total: {total()}"), # Note: call it like a function
]When defined outside a state class, you access the computed value by calling it as a function.
Effects
Effects are functions that run in response to state changes. They are useful for side effects like logging, analytics, or synchronizing with external systems.
Defining effects
Use the @ps.effect decorator on a method in your state class:
class SettingsState(ps.State):
theme: str = "light"
font_size: int = 14
@ps.effect
def save_to_storage(self):
# This runs whenever theme or font_size changes
storage.save({
"theme": self.theme,
"font_size": self.font_size,
})
print(f"Settings saved: {self.theme}, {self.font_size}px")When the effect runs, Pulse tracks which state properties it reads. Those become the effect's dependencies. The effect re-runs automatically whenever any dependency changes.
Effect cleanup
Effects can return a cleanup function that runs before the next execution and when the effect is disposed:
class TimerState(ps.State):
interval_ms: int = 1000
count: int = 0
@ps.effect
def start_timer(self):
# Set up the timer
timer_id = set_interval(lambda: self.tick(), self.interval_ms)
# Cleanup function: cancel the timer
def cleanup():
clear_interval(timer_id)
print("Timer stopped")
return cleanup
def tick(self):
self.count += 1The cleanup sequence is:
- Effect runs for the first time, returns cleanup function
- A dependency changes
- Cleanup function runs (cleans up the old timer)
- Effect runs again with new values (creates new timer)
- Component is removed from UI
- Cleanup function runs one final time
Effects in components
You can define effects directly inside components using @ps.effect. Pulse automatically registers them:
@ps.component
def TrackedComponent():
with ps.init():
state = MyState()
@ps.effect
def log_all_changes():
print(f"Name: {state.name}, Count: {state.count}")
return ps.div(...)For effects in loops, use the key parameter:
@ps.component
def MultiLogger():
with ps.init():
items = ["a", "b", "c"]
for item in items:
@ps.effect(key=item)
def log_item(item=item): # Capture via default arg
print(f"Item changed: {item}")
return ps.div(...)Controlling reactivity
Sometimes you need fine-grained control over the reactivity system. Pulse provides two utilities for this.
ps.Untrack() - Ignore dependencies
Use ps.Untrack() to read state without creating a dependency:
class AnalyticsState(ps.State):
page_views: int = 0
debug_mode: bool = False
@ps.effect
def track_page_view(self):
self.page_views += 1
# Read debug_mode without making it a dependency
with ps.Untrack():
if self.debug_mode:
print(f"Page view: {self.page_views}")
# The effect only re-runs when page_views changes,
# NOT when debug_mode changesThis is useful when you want to read a value for logging or conditional logic without triggering re-runs when that value changes.
ps.Batch() - Group updates
Use ps.Batch() to group multiple state updates into a single re-render:
class FormState(ps.State):
name: str = ""
email: str = ""
phone: str = ""
def reset(self):
# Without batching: 3 separate re-renders
# With batching: 1 re-render
with ps.Batch():
self.name = ""
self.email = ""
self.phone = ""Pulse automatically batches synchronous updates in event handlers, so you usually do not need this. It is mainly useful in effects or when updating state from external code.
Complete example
Here is a component that demonstrates computed values and effects together:
from dataclasses import dataclass
import pulse as ps
@dataclass
class Todo:
id: int
text: str
done: bool = False
class TodoState(ps.State):
todos: list[Todo] = []
filter: str = "all"
_next_id: int = 1
@ps.computed
def filtered_todos(self) -> list[Todo]:
if self.filter == "active":
return [t for t in self.todos if not t.done]
if self.filter == "completed":
return [t for t in self.todos if t.done]
return self.todos
@ps.computed
def stats(self) -> dict:
total = len(self.todos)
done = len([t for t in self.todos if t.done])
return {"total": total, "done": done, "remaining": total - done}
@ps.effect
def log_changes(self):
stats = self.stats
print(f"Todos: {stats['total']} total, {stats['remaining']} remaining")
def add(self, text: str):
self.todos.append(Todo(self._next_id, text))
self._next_id += 1
def toggle(self, todo_id: int):
for todo in self.todos:
if todo.id == todo_id:
todo.done = not todo.done
break
@ps.component
def TodoApp():
with ps.init():
state = TodoState()
new_text = ""
def handle_add():
nonlocal new_text
if new_text.strip():
state.add(new_text)
new_text = ""
stats = state.stats
return ps.div()[
ps.h1("Todo List"),
# Add new todo
ps.div()[
ps.input(
type="text",
value=new_text,
onChange=lambda e: setattr(state, "new_text", e["target"]["value"]),
placeholder="What needs to be done?",
),
ps.button("Add", onClick=handle_add),
],
# Filter buttons
ps.div()[
ps.button("All", onClick=lambda: setattr(state, "filter", "all")),
ps.button("Active", onClick=lambda: setattr(state, "filter", "active")),
ps.button("Completed", onClick=lambda: setattr(state, "filter", "completed")),
],
# Todo list
ps.ul()[
ps.For(
state.filtered_todos,
lambda t: ps.li(
ps.input(type="checkbox", checked=t.done, onChange=lambda: state.toggle(t.id)),
ps.span(t.text),
key=t.id,
)
)
],
# Stats
ps.p(f"{stats['remaining']} of {stats['total']} remaining"),
]When to use what
-
Computed values: Use for derived data that can be calculated from state. They are efficient because they cache results.
-
Effects: Use for side effects that should happen in response to state changes: logging, analytics, syncing to storage, etc.
-
Avoid effects for derived data: If you need to transform state into something else, use a computed value instead of an effect that updates another state property.