Ctrl + K

Lifecylce

The lifecycle of a Component

Lifecycle

Pulsar exposes a minimal, explicit lifecycle designed for long-lived components. Lifecycle hooks exist to model real transitions — not render cycles.

Unlike reactive frameworks that constantly mount, unmount and re-run effects, Pulsar components are created once and evolve over time. The lifecycle reflects this philosophy.

The Three Moments

A component has exactly three lifecycle moments:

  • Creation — The constructor runs. The component instance is born.
  • Morphing — State changes. The component updates its output.
  • Disposal — The runtime shuts down. The component is discarded.

That's it. No mount/unmount cycles during normal operation. No effects that re-run. Just creation, evolution, and eventual disposal.

Core principle: Components are not recreated during navigation, conditional rendering, or updates. They persist and morph.

Creation (Constructor)

A component is instantiated exactly once. This is where identity is established and long-lived dependencies are initialized.

final class UserProfile extends Component {
  final String userId;
  final UserService service = UserService();
  
  UserProfile(this.userId);

  @override
  Morphic render() {
    return Section().classes("user-profile")([
      H1()(['User: $userId']),
      P()(['Profile loaded']),
    ]);
  }
}

Fields defined on the component instance persist for its entire lifetime. They are not reset by renders, navigation, or state changes.

When to Use the Constructor

  • Initialize fields and state
  • Store constructor parameters
  • Create service instances or domain objects
  • Set up initial configuration
  • Start async initialization

Async Initialization

If you need to perform async work during initialization, call an async method from the constructor:

final class DataLoader extends Component {
  bool loading = true;
  List<Morphic> items = [];
  
  DataLoader() {
    _loadData();
  }
  
  Future<void> _loadData() async {
    final data = await fetchApiData();
    
    if (attached) {
      morph(() {
        // Transform data to Morphic HERE
        items = data.map((item) => Li()([item])).toList();
        loading = false;
      });
    }
  }

  @override
  Morphic render() {
    if (loading) {
      return Div().classes("loading")(['Loading data...']);
    }

    return Ul().classes("data-list")([items]);
  }
}

The async method runs after construction, fetches data, and then calls morph() to update the UI. This works because by the time the async operation completes, the component is already attached.

Real Example with HTTP

Here's a complete working example fetching data from an API:

import 'package:http/http.dart' as http;
import 'dart:convert';

final class CoffeeList extends Component {
  List<Morphic> coffees = [];
  
  CoffeeList() {
    _fetchCoffees();
  }
  
  Future<void> _fetchCoffees() async {
    final response = await http.get(
      Uri.parse('https://api.example.com/coffees'),
    );
    
    final items = json.decode(response.body)['items'] as List;
    
    if (attached) {
      morph(() {
        // Transform to Morphic in morph(), NOT in render()
        coffees = items
          .map((item) => Li()([item['name'] as String]))
          .toList();
      });
    }
  }

  @override
  Morphic render() {
    // Pure description - no transformation
    return Section().classes("coffee-list")([
      H2()(['Available Coffees']),
      Ul()([coffees]),
    ]);
  }
}

Key points in this pattern:

  • Guard with attached: Check if (attached) before calling morph() to prevent errors.
  • Transform to Morphic early: Convert API data to Morphic nodes in the morph() callback, NEVER in render().
  • Keep render() pure: render() just describes structure. No data transformation.

Note: You do NOT need special timing tricks. Just call your async method from the constructor and guard with if (attached) before morphing.

Advanced: Domain State Objects

For complex async logic or reusable data-fetching patterns, extract the state management into a domain object:

// Domain object - manages data fetching logic
class CoffeeLoader {
  List<Morphic> coffees = [];
  bool loading = true;
  String? error;
  
  Future<void> fetch() async {
    loading = true;
    error = null;
    
    try {
      final response = await http.get(
        Uri.parse('https://api.example.com/coffees'),
      );
      
      final items = json.decode(response.body)['items'] as List;
      
      // Transform to Morphic in the domain object
      coffees = items
        .map((item) => Li()([item['name'] as String]))
        .toList();
      
      loading = false;
    } catch (e) {
      error = e.toString();
      loading = false;
    }
  }
}
final class CoffeeList extends Component {
  final CoffeeLoader loader = CoffeeLoader();
  
  CoffeeList() {
    _loadCoffees();
  }
  
  Future<void> _loadCoffees() async {
    await loader.fetch();
    
    if (attached) {
      morph(() {});  // Trigger re-render with new loader state
    }
  }

  @override
  Morphic render() {
    if (loader.loading) {
      return Section().classes("coffee-list")([
        H2()(['Loading coffees...']),
      ]);
    }
    
    if (loader.error != null) {
      return Section().classes("coffee-list")([
        H2()(['Error loading coffees']),
        P()([loader.error!]),
      ]);
    }
    
    return Section().classes("coffee-list")([
      H2()(['Available Coffees']),
      Ul()([loader.coffees]),
    ]);
  }
}

This pattern offers several benefits:

  • Separation of concerns: Data fetching logic lives in CoffeeLoader, UI description in render().
  • Reusability: CoffeeLoader can be used by multiple components or tested independently.
  • Pure render: render() stays pure — it only reads loader state, never transforms data.

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

Morphing (State Updates)

When state changes, you call morph(). This triggers a re-render and then calls onMorph().

final class Counter extends Component {
  int count = 0;

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

  @override
  void onMorph() {
    print('Counter morphed: $count');
  }

  @override
  Morphic render() {
    return Button().onClick(increment)(['Count: $count']);
  }
}

onMorph() is called after each successful state update. It allows you to react to state changes without affecting rendering logic.

When to Use onMorph()

  • Logging or analytics after state changes
  • Syncing derived state
  • Triggering side effects based on new state
  • Debug inspection of state transitions

When NOT to Use onMorph()

  • Computing derived values (use getters instead)
  • Rendering logic (belongs in render())
  • Initialization (use constructor)

Rendering: Pure Description

The render() method is not part of the lifecycle in the traditional sense. It's a pure description of what the UI should look like given the current state.

Critical principle: render() must be pure in its UI description. It should read like a structural declaration, not executable code with transformations.

The Getter Pattern - Always Use This

When you need to transform data for display, ALWAYS use getters:

final class TodoList extends Component {
  List<String> todos = ['Buy milk', 'Write code'];
  
  //  Getter - transforms data OUTSIDE render
  List<Morphic> get todoItems => 
    todos.map((todo) => Li()([todo])).toList();
  
  void addTodo(String text) {
    morph(() => todos.add(text));
  }

  @override
  Morphic render() {
    //  Pure description - just references the getter
    return Div().classes("todo-list")([
      H2()(['My Todos']),
      Ul()([todoItems]),
    ]);
  }
}

The getter pattern keeps render() pure. It reads as: "Show a div with a heading and a list of todo items." No transformation logic visible.

What IS Allowed in render()

Structural control flow that describes which UI to show:

@override
Morphic render() {
  //  If/else - describes UI variation
  if (loading) {
    return Div()(['Loading...']);
  }
  
  if (error != null) {
    return ErrorView(error);
  }
  
  //  Switch - describes UI structure
  return switch (viewMode) {
    ViewMode.list => _renderList(),
    ViewMode.grid => _renderGrid(),
    ViewMode.table => _renderTable(),
  };
  
  //  Ternary - simple UI variation
  return showDetails ? DetailView() : SummaryView();
  
  //  Private methods - extract complexity
  return _buildMainLayout();
}

These constructs describe which UI to show, not how to transform data.

What is FORBIDDEN in render()

NEVER put data transformations in render():

@override
Morphic render() {
  //  FORBIDDEN - map() transforms data
  return Ul()(
    todos.map((todo) => Li()([todo])).toList(),
  );
  
  //  FORBIDDEN - where() filters data
  final activeItems = items.where((i) => i.active).toList();
  
  //  FORBIDDEN - fold() computes values
  final total = items.fold(0, (sum, i) => sum + i.price);
  
  //  FORBIDDEN - any data transformation
  final sorted = items.toList()..sort((a, b) => a.compareTo(b));
  
  return Div()([...]);
}

Anti-Pattern Warning: Using map(), where(), fold(), or any data transformation inside render() is an anti-pattern in Pulsar. Always use getters instead.

Complex Transformations - Multiple Getters

final class ItemList extends Component {
  List<Item> items = [];
  
  //  Chain of getters - all logic outside render
  List<Item> get activeItems => 
    items.where((i) => i.active).toList();
  
  List<Item> get sortedItems {
    final sorted = activeItems.toList();
    sorted.sort((a, b) => a.name.compareTo(b.name));
    return sorted;
  }
  
  List<Morphic> get itemElements => 
    sortedItems.map((item) => Li()([item.name])).toList();
  
  double get totalPrice => 
    activeItems.fold(0.0, (sum, i) => sum + i.price);

  @override
  Morphic render() {
    //  Pure - just reads computed values
    return Div().classes("item-list")([
      H2()(['Total: $${totalPrice.toStringAsFixed(2)}']),
      Ul()([itemElements]),
    ]);
  }
}

The Readability Test

//  GOOD - Reads as: "If loading, show spinner. Otherwise show items."
@override
Morphic render() {
  if (loading) return Spinner();
  return Ul()([items]);
}

//  BAD - Reads as: "Transform data, filter, sort, THEN show."
@override
Morphic render() {
  final transformed = data.map((d) => transform(d)).toList();
  final filtered = transformed.where((t) => t.isValid).toList();
  final sorted = filtered..sort((a, b) => a.compareTo(b));
  return Ul()(sorted.map((s) => Li()([s.name])).toList());
}

//  GOOD - Reads as: "Show list of processed items."
List<Morphic> get processedItems {
  return data
    .map((d) => transform(d))
    .where((t) => t.isValid)
    .toList()
      ..sort((a, b) => a.compareTo(b));
}

List<Morphic> get processedElements =>
  processedItems.map((s) => Li()([s.name])).toList();

@override
Morphic render() {
  return Ul()([processedElements]);
}

Guiding principle: render() should read like a declaration of structure, not like executable code. If someone reading render() has to understand data transformation to understand the UI, it violates the Pure Description rule.

No Unmount During Normal Operation

This is crucial: components do NOT unmount during normal application flow.

  • Navigation between routes → Components morph, not unmount
  • Conditional rendering → Components morph, not unmount
  • State updates → Components morph, not unmount
  • Parent re-renders → Child components persist

Components only get discarded when the entire Pulsar runtime shuts down (page reload, tab close, manual unmount).

Mental shift: If you're coming from React or similar frameworks, this is the biggest difference. Components don't get destroyed and recreated constantly. They live and evolve.

Resource Management

Since components don't unmount during normal operation, resources are managed explicitly:

Pattern: Manual Cleanup Methods

final class AutoSaver extends Component {
  Timer? _saveTimer;
  
  AutoSaver() {
    _startAutoSave();
  }
  
  void _startAutoSave() {
    _saveTimer = Timer.periodic(
      Duration(seconds: 30),
      (_) => _performSave(),
    );
  }
  
  void stopAutoSave() {
    _saveTimer?.cancel();
    _saveTimer = null;
  }

  void _performSave() {
    // Save logic
  }

  @override
  Morphic render() {
    return Div().classes("auto-saver")([
      P()(['Auto-saving every 30 seconds']),
      Button().onClick((_) => stopAutoSave())(['Stop Auto-save']),
    ]);
  }
}

Resources are managed through explicit methods, not automatic cleanup hooks.

Comparing to Other Frameworks

//  React/Flutter pattern (NOT how Pulsar works)
class CounterReact extends Component {
  // Component recreated on every parent render
  // useEffect runs cleanup and re-runs
  // State externalized or managed by hooks
}

//  Pulsar pattern
final class Counter extends Component {
  int count = 0;  // State lives here, persists
  
  Counter() {
    // Runs once, component created
  }
  
  void increment(Event e) {
    morph(() => count++);  // State changes, component morphs
  }
  
  @override
  void onMorph() {
    // Runs after each morph (optional)
  }
  
  @override
  Morphic render() {
    // Pure description, re-runs on morph
    return Button().onClick(increment)(['Count: $count']);
  }
}

What Pulsar Intentionally Avoids

  • No mount/unmount cycles during normal updates — Components don't get destroyed and recreated.
  • No effect re-execution — No hooks that re-run on every render.
  • No cleanup-as-primary-model — Resources are managed explicitly, not via automatic cleanup.
  • No data transformation in render() — Use getters to keep render() pure.

Lifecycle Summary

final class FullLifecycleExample extends Component {
  List<String> data = [];
  
  // Getter for transformation
  List<Morphic> get dataElements =>
    data.map((item) => Li()([item])).toList();
  
  // 1. CREATION - Constructor runs once
  FullLifecycleExample() {
    print('Component created');
    _initialize();
  }
  
  // Async initialization
  Future<void> _initialize() async {
    final items = await loadInitialData();
    
    if (attached) {
      morph(() => data = items);
    }
  }
  
  // 2. MORPHING - Called after each state update
  @override
  void onMorph() {
    print('Component morphed');
  }
  
  // 3. RENDERING - Pure description
  @override
  Morphic render() {
    return Ul()([dataElements]);  // No transformation here
  }
  
  // 4. CLEANUP - Manual methods when needed
  void cleanup() {
    print('Manual cleanup');
  }
}

Best Practices

  • Always use getters for data transformation — NEVER transform data in render().
  • Guard async morph calls — Use if (attached) before calling morph() in async methods.
  • Transform to Morphic early — Convert data to Morphic nodes in morph() or getters, not in render().
  • Keep render() pure — No side effects, no transformations, only structural control flow.
  • Use onMorph() sparingly — Only for post-update side effects, not for core logic.
  • Make cleanup explicit — Public methods for resource cleanup, not automatic hooks.

Anti-Patterns

  • ❌ Using map(), where(), fold() or any transformation in render()
  • ❌ Calling morph() without checking if (attached) in async methods
  • ❌ Performing side effects in render()
  • ❌ Using onMorph() for computed values (use getters)
  • ❌ Expecting components to unmount during navigation
  • ❌ Instantiating components in router builders instead of using fields

Philosophy: If your design requires frequent mounting and cleanup, it indicates a mismatch with Pulsar's mental model. Components are meant to persist. If you find yourself fighting the lifecycle, reconsider whether you're designing for long-lived objects or disposable functions.