Hooks
Hooks are special functions that let you "hook into" Pulse's render cycle. They provide a way to preserve values across renders, run setup code once, and manage side effects. If you have used React before, you will find these concepts familiar, though Pulse hooks work a bit differently.
The core idea
When a component renders, it runs from top to bottom like any Python function. But what happens when the component re-renders after a state change? By default, all local variables would be recreated from scratch.
Hooks solve this problem. They let you say: "Remember this value between renders" or "Run this code only the first time."
The main hooks
Pulse has five main hooks. Let's explore each one.
ps.init() - Preserve variables across renders
The most common hook is ps.init(). It captures any variables you assign inside its block on the first render and restores them on subsequent renders.
@ps.component
def Counter():
with ps.init():
count_state = CounterState()
some_value = expensive_calculation()
# count_state and some_value are the same objects on every render
return ps.div(f"Count: {count_state.count}")This is essential for state management. Without ps.init(), you would create a new CounterState every render, losing all your data.
To see why this matters, try removing ps.init():
@ps.component
def BrokenCounter():
# Bug: Creates a new state every render!
count_state = CounterState()
return ps.button("Click me", onClick=count_state.increment)Clicking the button increments the count and triggers a re-render. But the re-render creates a fresh CounterState with count=0. The button appears to do nothing.
How ps.init() works (a bit of magic)
ps.init() uses AST rewriting to transform your code. When you apply the @ps.component decorator, Pulse parses your function's source code and rewrites with ps.init(): blocks.
Because of this:
ps.init()only works at the top level of the component function (not insideif,for, loops, or nested functions)- You cannot have control flow inside the block (no
if,for,while,try,with,match)
Equivalent to ps.setup()
Under the hood, ps.init() is syntactic sugar for ps.setup(). This:
@ps.component
def Counter():
with ps.init():
state = CounterState()
value = expensive_calculation()
return ps.div(f"Count: {state.count}")Is equivalent to:
@ps.component
def Counter():
def init():
return CounterState(), expensive_calculation()
state, value = ps.setup(init)
return ps.div(f"Count: {state.count}")If you encounter issues with ps.init() (e.g., source not available in some deployment environments), use ps.setup() instead.
ps.setup() - Run a function once
Sometimes you need to run initialization code that is more complex than simple variable assignment. That is where ps.setup() comes in:
def initialize_editor():
print("Setting up editor...")
config = load_config()
state = EditorState(config)
return state
@ps.component
def Editor():
# initialize_editor runs only on the first render
state = ps.setup(initialize_editor)
return ps.div(...)You can also pass arguments to the setup function:
def create_connection(host, port):
return DatabaseConnection(host, port)
@ps.component
def Dashboard():
conn = ps.setup(create_connection, "localhost", port=5432)
return ps.div(...)The function runs once, its return value is cached, and subsequent renders return the cached value.
ps.state() - Inline state instances
Use ps.state() to create state instances inline that persist across renders:
@ps.component
def Counter():
counter = ps.state(lambda: CounterState()) # Factory function
# Or pass a State instance directly:
# counter = ps.state(CounterState())
return ps.button(f"Count: {counter.count}", onClick=counter.increment)Pulse identifies each ps.state() call by its code location, so you can have multiple inline states without explicit keys:
@ps.component
def Dashboard():
stats = ps.state(StatsState()) # Location 1
settings = ps.state(SettingsState()) # Location 2 - different state
return ps.div(...)Loop states with keys
When using ps.state() inside a loop, provide a unique key to disambiguate each instance:
@ps.component
def UserList():
with ps.init():
user_ids = ["alice", "bob", "charlie"]
items = []
for user_id in user_ids:
user_state = ps.state(lambda uid=user_id: UserState(uid), key=user_id)
items.append(ps.li(user_state.name, key=user_id))
return ps.ul(*items)Dynamic keys for re-initialization
When the key changes, the old state is disposed and a new one is created:
@ps.component
def UserProfile(user_id: int):
# State is recreated when user_id changes
state = ps.state(lambda: UserState(user_id), key=f"user-{user_id}")
return ps.div(f"User: {state.name}")@ps.effect - Inline side effects
Effects let you run code in response to state changes. When used inside a component, @ps.effect automatically registers the effect:
@ps.component
def Logger():
with ps.init():
state = LogState()
@ps.effect
def log_changes():
print(f"Value changed to: {state.value}")
return ps.div(...)For effects in loops, use the key parameter:
@ps.component
def ItemLogger():
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: {item}")
return ps.div(...)Effects are covered in detail in the Reactivity section.
ps.stable() - Stable references
The ps.stable() hook solves a common problem: keeping a stable reference to a value that might change.
Consider this scenario:
@ps.component
def Editor(on_save):
with ps.init():
# Problem: on_save might change between renders,
# but EditorState captured the old version
state = EditorState(on_save)
return ps.div(...)If the parent component passes a new on_save function, your EditorState still has the old one. Using ps.stable() fixes this:
@ps.component
def Editor(on_save):
# on_save_ref always calls the latest on_save
on_save_ref = ps.stable("on_save", on_save)
with ps.init():
state = EditorState(on_save_ref)
return ps.div(...)The ps.stable() hook returns a wrapper function that always calls the latest version of on_save. The wrapper's identity never changes, so it is safe to pass to state constructors or use in effects.
For non-function values, call the returned reference to get the value:
config_ref = ps.stable("config", config)
current_config = config_ref() # Get the latest configThe rules of hooks
Hooks have one important rule: call each hook only once per component.
This works:
@ps.component
def GoodComponent():
with ps.init():
state1 = StateA()
state2 = StateB()
return ps.div(...)This breaks:
@ps.component
def BadComponent():
# Don't do this! Multiple ps.init() calls
with ps.init():
state1 = StateA()
with ps.init(): # Error!
state2 = StateB()
return ps.div(...)The rule exists because hooks rely on call order to track their state internally. If you need multiple states, create them all inside a single ps.init() block.
Hook keys
Both ps.setup() and ps.state() accept an optional key argument. When the key changes, the hook re-initializes:
@ps.component
def DataLoader(data_id: int):
# Re-runs load_data whenever data_id changes
data = ps.setup(load_data, data_id, key=data_id)
return ps.div(f"Data: {data}")@ps.component
def UserProfile(user_id: int):
# State is recreated whenever user_id changes
state = ps.state(lambda: UserState(user_id), key=f"user-{user_id}")
return ps.div(f"User: {state.name}")Keys are compared using !=, so use primitive values (strings, numbers, booleans) or tuples of primitives.
Similarly, @ps.effect accepts a key parameter for use in loops or to force effect recreation:
@ps.component
def DynamicEffect():
with ps.init():
version = ps.Signal("v1")
@ps.effect(key=version()) # Effect recreated when version changes
def versioned_effect():
print(f"Running with version: {version()}")
return ps.div(...)Complete example
Here is a component that demonstrates multiple hooks working together:
import pulse as ps
class FormState(ps.State):
name: str = ""
email: str = ""
def __init__(self, on_submit):
self._on_submit = on_submit
def submit(self):
self._on_submit({"name": self.name, "email": self.email})
@ps.component
def ContactForm(on_submit):
# Keep a stable reference to on_submit
on_submit = ps.stable("on_submit", on_submit)
# Initialize state once
with ps.init():
state = FormState(on_submit)
return ps.form(
ps.input(
type="text",
placeholder="Name",
value=state.name,
onChange=lambda e: setattr(state, "name", e["target"]["value"]),
),
ps.input(
type="email",
placeholder="Email",
value=state.email,
onChange=lambda e: setattr(state, "email", e["target"]["value"]),
),
ps.button("Submit", type="button", onClick=state.submit),
)
@ps.component
def App():
def handle_submit(data):
print(f"Form submitted: {data}")
return ps.div(
ps.h1("Contact Us"),
ContactForm(on_submit=handle_submit),
)