Ctrl + K

State Management

Manage component's state.

State Management

State management in Pulsar is intentionally explicit and minimal. State lives inside components, has a clear owner, and evolves over time through direct mutation using morph().

Pulsar does not introduce reactive stores, signals or global state systems. Instead, it relies on a small set of compositional patterns that preserve clarity, ownership and predictability.

Local State (Core Pattern)

Local state is the foundation of all state management in Pulsar. State is stored directly on the component instance and mutated explicitly using morph().

If a piece of state conceptually belongs to a component, it should live inside that component. This keeps ownership obvious and avoids unnecessary indirection.

final class Counter extends Component {
  int count = 0;

  void increment(Event event) {
    morph(() => count++);
  }
  
  void decrement(Event event) {
    morph(() => count--);
  }

  @override
  Morphic render() {
    return Div().classes("counter")([
      Button().onClick(decrement)(['-']),
      Span()(['Count: $count']),
      Button().onClick(increment)(['+']),
    ]);
  }
}

The state (count) lives in the component. It is mutated through morph(), which triggers a re-render. No external state management needed.

Use local state when: the state affects only a single component and does not need to be shared or coordinated with other components.

Lifting State Up

When multiple components need to read or affect the same state, lift that state to the closest common parent.

This pattern is simple: one component owns the state, others receive it as parameters and send updates via callbacks.

final class Parent extends Component {
  int count = 0;

  void increment(Event event) {
    morph(() => count++);
  }
  
  void decrement(Event event) {
    morph(() => count--);
  }

  @override
  Morphic render() {
    return Div().classes("parent")([
      H2()(['Shared Counter']),
      Display(count),
      Controls(
        onIncrement: increment,
        onDecrement: decrement,
      ),
    ]);
  }
}

final class Display extends Component {
  final int value;
  
  Display(this.value);

  @override
  Morphic render() {
    return Div().classes("display")([
      P()(['Current value: $value']),
    ]);
  }
}

final class Controls extends Component {
  final void Function(Event) onIncrement;
  final void Function(Event) onDecrement;
  
  Controls({
    required this.onIncrement,
    required this.onDecrement,
  });

  @override
  Morphic render() {
    return Div().classes("controls")([
      Button().onClick(onDecrement)(['Decrement']),
      Button().onClick(onIncrement)(['Increment']),
    ]);
  }
}

Key principles in this pattern:

  • State has a single, clear owner (Parent)
  • Data flows down via constructor parameters
  • Events flow up via callbacks
  • No implicit subscriptions or observers

Important: Even though Display and Controls don't own mutable state, they are still long-lived components with identity. They persist across Parent re-renders.

Complex State with Getters

For derived or computed state, use getters. This keeps render() pure while making complex state logic reusable.

final class ShoppingCart extends Component {
  List<CartItem> items = [];
  
  // Computed state via getters
  String get subtotal => items
      .fold(0.0, (sum, item) => sum + item.price * item.quantity)
      .toStringAsFixed(2);

  String get tax => (double.parse(subtotal) * 0.1).toStringAsFixed(2);

  String get total =>
      (double.parse(subtotal) + double.parse(tax)).toStringAsFixed(2);
  
  int get itemCount => 
    items.fold(0, (sum, item) => sum + item.quantity);
  
  // UI elements via getter
  List<Morphic> get cartItems =>
    items.map((item) => Li()([item])).toList();
  
  void addItem(CartItem item) {
    morph(() => items.add(item));
  }
  
  void removeItem(String id) {
    morph(() => items.removeWhere((item) => item.id == id));
  }

  @override
  Morphic render() {
    // Pure description - reads computed values
    return Div().classes("shopping-cart")([
      H2()(['Cart ($itemCount items)']),
      Div().classes("items")([cartItems]),
      Div().classes("summary")([
        P()(['Subtotal: $subtotal']),
        P()(['Tax: $tax']),
        P()(['Total: $total']),
      ]),
    ]);
  }
}

Getters keep all computation logic outside render(). This makes the code testable, reusable, and maintains the Pure Description principle.

State Across Multiple Components

When state needs to be shared across components that are not in a parent-child relationship, lift the state to a common ancestor.

final class DashboardPage extends Component {
  // State owned by dashboard
  User? currentUser;
  List<Notification> notifications = [];
  
  void updateUser(User user) {
    morph(() => currentUser = user);
  }
  
  void addNotification(Notification notif) {
    morph(() => notifications.add(notif));
  }

  @override
  Morphic render() {
    if (currentUser == null) {
      return LoginView(onLogin: updateUser);
    }
    
    return Div().classes("dashboard")([
      Navbar(
        user: currentUser!,
        notificationCount: notifications.length,
      ),
      Sidebar(user: currentUser!),
      MainContent(
        user: currentUser!,
        onNotification: addNotification,
      ),
    ]);
  }
}

DashboardPage owns the shared state and passes it down to Navbar, Sidebar, and MainContent. Each component receives only what it needs.

Domain Objects for Complex Logic

For reusable or complex state logic, extract it into a domain object. This is recommended when logic becomes repetitive or awkward to duplicate across multiple components.

// Domain object - manages pagination logic
class PaginationState {
  int currentPage = 1;
  int itemsPerPage = 10;
  int totalItems = 0;
  
  int get totalPages => (totalItems / itemsPerPage).ceil();
  int get startIndex => (currentPage - 1) * itemsPerPage;
  int get endIndex => (startIndex + itemsPerPage).clamp(0, totalItems);
  bool get hasNext => currentPage < totalPages;
  bool get hasPrev => currentPage > 1;
  
  void nextPage() {
    if (hasNext) currentPage++;
  }
  
  void prevPage() {
    if (hasPrev) currentPage--;
  }
  
  void goToPage(int page) {
    if (page >= 1 && page <= totalPages) {
      currentPage = page;
    }
  }
}
final class ItemList extends Component {
  final PaginationState pagination = PaginationState();
  List<Item> allItems = [];
  
  ItemList() {
    _loadItems();
  }
  
  Future<void> _loadItems() async {
    final items = await fetchItems();
    
    if (attached) {
      morph(() {
        allItems = items;
        pagination.totalItems = items.length;
      });
    }
  }
  
  // Getters for current page
  List<Item> get currentItems =>
    allItems.sublist(
      pagination.startIndex,
      pagination.endIndex,
    );
  
  List<Morphic> get itemElements =>
    currentItems.map((item) => ItemCard(item)).toList();
  
  void handleNext(Event e) {
    morph(() => pagination.nextPage());
  }
  
  void handlePrev(Event e) {
    morph(() => pagination.prevPage());
  }

  @override
  Morphic render() {
    return Div().classes("item-list")([
      Div().classes("items")([itemElements]),
      Div().classes("pagination")([
        Button()
          .onClick(handlePrev)
          .disabled(!pagination.hasPrev)(['Previous']),
        Span()(['Page ${pagination.currentPage} of ${pagination.totalPages}']),
        Button()
          .onClick(handleNext)
          .disabled(!pagination.hasNext)(['Next']),
      ]),
    ]);
  }
}

The domain object (PaginationState) encapsulates pagination logic. The component owns the instance and triggers morphs when pagination changes.

Key principle: Domain objects do not trigger renders. The component remains the owner of rendering via morph(). Domain objects just manage state and expose it for the component to read.

When to Use Domain Objects

  • Complex logic that would clutter the component
  • Reusable patterns across multiple components
  • Logic that needs independent testing
  • State machines or complex workflows

When NOT to Use Domain Objects

  • Simple state (use fields directly)
  • Component-specific logic (keep it in the component)
  • Before the need is clear (avoid premature abstraction)

State Patterns Summary

Pulsar provides three compositional patterns for state management:

  • Local State — State lives in component fields. Use when state is component-specific.
  • Lifting State Up — State lives in common ancestor. Use when multiple components share state.
  • Domain Objects — State lives in separate objects. Use for complex, reusable logic.

All three patterns follow the same principle: explicit ownership, explicit mutation via morph(), and pure render().

Data Flow Principles

State management in Pulsar follows simple, predictable rules:

  • Unidirectional Flow — Data flows down (parameters), events flow up (callbacks).
  • Single Owner — Every piece of state has exactly one owner.
  • Explicit Mutation — State changes only through morph(), never directly.
  • No Hidden Dependencies — All dependencies are visible in constructor parameters.
//  GOOD - Explicit data flow
final class Parent extends Component {
  String text = '';  // Owner of state
  
  void handleChange(String newText) {
    morph(() => text = newText);  // Explicit mutation
  }

  @override
  Morphic render() {
    return Child(
      value: text,            // Data flows down
      onChange: handleChange, // Events flow up
    );
  }
}

final class Child extends Component {
  final String value;
  final void Function(String) onChange;
  
  Child({required this.value, required this.onChange});  // Explicit deps
  
  void handleInput(Event e) {
    final target = e.target as HTMLInputElement;
    onChange(target.value ?? '');  // Call callback
  }

  @override
  Morphic render() {
    return Input().value(value).onInput(handleInput);
  }
}

What Pulsar Avoids

Pulsar intentionally does not include:

  • Global implicit state — No automatic context or dependency injection by default.
  • Reactive stores — No observables, signals, or automatic subscriptions.
  • Hidden data propagation — All data flow is explicit and visible.
  • Framework-managed state — State lives in your components, not in the framework.

These patterns exist in other frameworks, but they introduce indirection and make data flow harder to trace. Pulsar favors explicitness.

Philosophy: Pulsar does not try to solve state management for you with clever abstractions. It gives you stable, predictable building blocks (components, fields, morph) and lets patterns emerge from composition.

Best Practices

  • Keep state as close as possible to where it is used — Avoid lifting state prematurely.
  • Lift state only when multiple components truly share it — Don't lift "just in case".
  • Use getters for derived state — Keep computation logic out of render().
  • Make ownership clear — It should be obvious who owns each piece of state.
  • Use domain objects for reusable, complex logic — But only when the need is clear.
  • Document state ownership in complex components — Comments help future readers.

Anti-Patterns

  • ❌ Mutating state outside of morph()
  • ❌ Creating global state when local state is sufficient
  • ❌ Hiding state ownership behind helpers or utilities
  • ❌ Over-engineering with domain objects before the need emerges
  • ❌ Using getters that have side effects
  • ❌ Sharing state through implicit mechanisms

Example: Complete Todo App

Here's a complete example showing all patterns together:

class TodoList {
  List<Todo> items = [];
  
  void add(String text) {
    items.add(
      Todo(id: DateTime.now().toString(), text: text, completed: false),
    );
  }
  
  void toggle(String id) {
    final todo = items.firstWhere((t) => t.id == id);
    todo.completed = !todo.completed;
  }
  
  void remove(String id) {
    items.removeWhere((t) => t.id == id);
  }
  
  List<Todo> get active => items.where((t) => !t.completed).toList();
  List<Todo> get completed => items.where((t) => t.completed).toList();
}
 
// Main app component
final class TodoApp extends Component {
  final TodoList todos = TodoList();
  String inputValue = '';
  Filter currentFilter = Filter.all;
  
  // Computed state
  List<Todo> get filteredTodos {
    return switch (currentFilter) {
      Filter.all => todos.items,
      Filter.active => todos.active,
      Filter.completed => todos.completed,
    };
  }
  
  List<TodoItem> get todoElements =>
    filteredTodos.map((todo) => TodoItem(
      todo: todo,
      onToggle: () => handleToggle(todo.id),
      onRemove: () => handleRemove(todo.id),
    )).toList();
  
  // Actions
  void handleAdd(Event e) {
    if (inputValue.trim().isEmpty) return;
    
    morph(() {
      todos.add(inputValue);
      inputValue = '';
    });
  }
  
  void handleInput(Event e) {
    final target = e.target as HTMLInputElement;
    inputValue = target.value;
  }
  
  void handleToggle(String id) {
    morph(() => todos.toggle(id));
  }
  
  void handleRemove(String id) {
    morph(() => todos.remove(id));
  }
  
  void setFilter(Filter filter) {
    morph(() => currentFilter = filter);
  }
 
  @override
  Morphic render() {
    return Div().classes("todo-app")([
      H1()(['Todo List']),
      
      // Input
      Div().classes("input-section")([
        Input()
          .placeholder('What needs to be done?')
          .value(inputValue)
          .onInput(handleInput)
          ([]), 
        Button().onClick(handleAdd)(['Add']),
      ]),
      
      // Filters
      Div().classes("filters")([
        Button()
          .onClick((_) => setFilter(Filter.all))
          .classes(currentFilter == Filter.all ? 'active' : '')(['All']),
        Button()
          .onClick((_) => setFilter(Filter.active))
          .classes(currentFilter == Filter.active ? 'active' : '')(['Active']),
        Button()
          .onClick((_) => setFilter(Filter.completed))
          .classes(currentFilter == Filter.completed ? 'active' : '')(['Completed']),
      ]),
      
      // Todo list
      Ul().classes("todo-list")([todoElements]),
      
      // Stats
      P()(['${todos.active.length} items left']),
    ]);
  }
}
 
// Child component
final class TodoItem extends Component {
  final Todo todo;
  final void Function() onToggle;
  final void Function() onRemove;
  
  TodoItem({
    required this.todo,
    required this.onToggle,
    required this.onRemove,
  });
 
  @override
  Morphic render() {
    return Li().classes(todo.completed ? 'completed' : '')([
      Input()
        .type(InputType.checkbox)
        .checked(todo.completed)
        .onChange((_) => onToggle())
        ([]),
      Span()([todo.text]),
      Button().onClick((_) => onRemove())(['×']),
    ]);
  }
}
 
enum Filter { all, active, completed }
 
class Todo {
  final String id;
  final String text;
  bool completed;
  
  Todo({required this.id, required this.text, required this.completed});
}

This example demonstrates:

  • Local state (inputValue, currentFilter)
  • Domain object (TodoList)
  • Computed state via getters
  • Lifting state (todos passed to TodoItem)
  • Explicit callbacks (onToggle, onRemove)
  • Pure render() using getters
  • Important: VoidElements like Input require () to invoke the call method.
  • Important: onInput does NOT use morph() to avoid losing focus while typing.

Guiding principle: If you can clearly answer "who owns this state and why", your state management is correct. Pulsar provides the primitives (components, fields, morph), you provide the architecture.