Pulse

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 inside if, 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 config

The 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),
    )

See also

What to read next

On this page