Ctrl + K

The Pulsar Web Mental Model

How to think about building applications with Pulsar Web

Mental Model

Pulsar is designed around a strict but simple mental model: components are long-lived objects with explicit identity. They are created once, kept alive, and evolved over time.

Rendering in Pulsar does not define identity. Ownership does. If you approach Pulsar with a functional-render mindset, many of its design decisions will feel counterintuitive.

The Fundamental Shift

Most modern frameworks treat components as functions that are called on every render. Pulsar treats components as objects that exist over time.

Functional Framework Mental Model

// Functional framework: Components are called like functions
Widget build() {
  return Column([
    UserCard(),  // Called every render
    ProfileInfo(),  // New instance every time
    StatsPanel(),  // Identity is inferred by position
  ]);
}

// Questions:
// - When is UserCard created?
// - Does it preserve state?
// - What happens to its lifecycle?
// All implicit, all inferred.

Pulsar Mental Model

// Pulsar: Components are objects with explicit ownership
final class ProfilePage extends Component {
  final UserCard userCard = UserCard();  // Created once
  final ProfileInfo profileInfo = ProfileInfo();  // Explicit ownership
  final StatsPanel statsPanel = StatsPanel();  // Identity preserved

  @override
  Morphic render() {
    return Div()([
      userCard,  // Same instance always
      profileInfo,
      statsPanel,
    ]);
  }
}

// Answers:
// - UserCard is created when ProfilePage is created
// - It preserves state because it's the same instance
// - Its lifecycle is tied to ProfilePage's lifecycle
// All explicit, all visible.

Key Insight: In Pulsar, you don't call components — you own them. Ownership is explicit, not inferred from render order or position in the tree.

How to Think About a Component

A component in Pulsar is an object with:

  • Identity — It exists as a specific instance
  • State — Fields that can change over time
  • Behavior — Methods that respond to events
  • Lifecycle — Created once, lives until owner is destroyed
  • Rendering — A pure description of current visual state

Component Lifecycle

final class Counter extends Component {
  int count = 0;  // State: Lives in the instance
  
  // Created once
  Counter() {
    print('Counter created');
  }
  
  // Behavior: Responds to events
  void increment(Event e) {
    morph(() => count++);  // Mutate state, trigger render
  }
  
  // Rendering: Pure description
  @override
  Morphic render() {
    print('render() called with count = $count');
    return Button().onClick(increment)(['Count: $count']);
  }
}

// Timeline:
// 1. Counter() constructor runs → "Counter created"
// 2. render() called → "render() called with count = 0"
// 3. User clicks button
// 4. increment() runs → count becomes 1
// 5. morph() triggers render() → "render() called with count = 1"
// 6. Same Counter instance, new visual state

render() is NOT a constructor. It is NOT a lifecycle hook. It is a pure function that describes what the UI should look like given current state.

Critical: render() is called multiple times during a component's lifetime. It should never have side effects, create new component instances, or mutate state. It only describes structure.

Ownership and Identity

A component must have a single, explicit owner. This owner determines when the component is mounted, updated, and eventually discarded.

Ownership Rules

  • Layouts own their child components — Stored as fields
  • Routers own route components — Via builders or fields
  • Components stored as fields preserve identity — Same instance across renders
  • Components created inside render() do not preserve identity — New instance every render

Ownership Patterns

// ✅ CORRECT: Explicit ownership via fields
final class DashboardPage extends Component {
  final Header header = Header();  // Owned by DashboardPage
  final Sidebar sidebar = Sidebar();  // Identity preserved
  final ContentArea content = ContentArea();  // Same instances always
  
  @override
  Morphic render() {
    return Div()([
      header,
      Div().classes("main")([
        sidebar,
        content,
      ]),
    ]);
  }
}

// ❌ WRONG: No ownership, identity lost
final class DashboardPage extends Component {
  @override
  Morphic render() {
    return Div()([
      Header(),  // ❌ New instance every render
      Div().classes("main")([
        Sidebar(),  // ❌ State lost
        ContentArea(),  // ❌ Identity broken
      ]),
    ]);
  }
}

// ⚠️ CONDITIONAL: When components don't need identity
final class AlertBanner extends Component {
  bool showAlert = true;

  @override
  Morphic render() {
    return Div()([
      if (showAlert)
        Div().classes("alert")(['Important message']),  // ✅ OK: Simple element
    ]);
  }
}

Router Ownership

// Router owns route components via builders
final router = Router(
  routes: [
    Route(path: '/', builder: (_) => HomePage()),  // New instance per navigation
    Route(path: '/about', builder: (_) => AboutPage()),
  ],
);

// For stateful routes, store as fields
final class App extends Component {
  final DashboardPage dashboard = DashboardPage();  // Preserve identity
  
  late final Router router = Router(
    routes: [
      Route(path: '/', builder: (_) => HomePage()),
      Route(path: '/dashboard', builder: (_) => dashboard),  // Returns field
    ],
  );
}

Domain Oriented UI Mental Model

Beyond component ownership, Pulsar encourages a Domain Oriented UI (DOU) mental model where components are thin coordinators, not business logic containers.

Three Layers of Separation

Think of your application in three distinct layers:

  • Domain Layer — Business logic, calculations, rules (pure Dart objects)
  • Styling Layer — Visual decisions, theming, conditional classes (domain styling objects)
  • UI Layer — Structure, what appears on screen (components)

DOU in Action

// ═══ DOMAIN LAYER ═══
// Business logic: Pure Dart, no UI dependencies
class Invoice {
  final List<InvoiceItem> items;
  final DateTime dueDate;
  
  Invoice(this.items, this.dueDate);
  
  double get subtotal =>
    items.fold(0.0, (sum, item) => sum + item.total);
  
  double get tax => subtotal * 0.1;
  double get total => subtotal + tax;
  
  bool get isOverdue => DateTime.now().isAfter(dueDate);
  bool get isPaid => /* check payment status */;
  
  InvoiceStatus get status {
    if (isPaid) return InvoiceStatus.paid;
    if (isOverdue) return InvoiceStatus.overdue;
    return InvoiceStatus.pending;
  }
}

enum InvoiceStatus { pending, paid, overdue }

// ═══ STYLING LAYER ═══
// Visual logic: Maps domain state to CSS
class InvoiceStyles {
  final InvoiceStatus status;
  final bool isOverdue;
  
  InvoiceStyles({required this.status, required this.isOverdue});
  
  String get statusClasses {
    return switch (status) {
      InvoiceStatus.paid => 'invoice-status status-paid',
      InvoiceStatus.overdue => 'invoice-status status-overdue',
      InvoiceStatus.pending => 'invoice-status status-pending',
    };
  }
  
  String get badgeClasses {
    return switch (status) {
      InvoiceStatus.paid => 'badge badge-success',
      InvoiceStatus.overdue => 'badge badge-danger',
      InvoiceStatus.pending => 'badge badge-warning',
    };
  }
  
  String get containerClasses =>
    isOverdue ? 'invoice-card invoice-overdue' : 'invoice-card';
}

// ═══ UI LAYER ═══
// Component: Pure structure, coordinates domain and styling
final class InvoiceCard extends Component {
  final Invoice invoice;
  
  InvoiceCard(this.invoice);
  
  InvoiceStyles get styles => InvoiceStyles(
    status: invoice.status,
    isOverdue: invoice.isOverdue,
  );

  @override
  Morphic render() {
    return Div().classes(styles.containerClasses)([
      Div().classes(styles.statusClasses)([
        Span().classes(styles.badgeClasses)([invoice.status.name.toUpperCase()]),
      ]),
      
      Div().classes("invoice-details")([
        P()(['Due: ${invoice.dueDate.toString().split(' ')[0]}']),
        P()(['Subtotal: $${invoice.subtotal.toStringAsFixed(2)}']),
        P()(['Tax: $${invoice.tax.toStringAsFixed(2)}']),
        P().classes("total")(['Total: $${invoice.total.toStringAsFixed(2)}']),
      ]),
    ]);
  }
}

Notice how each layer is isolated:

  • Invoice — Can be tested without UI, used in backend, serialized to JSON
  • InvoiceStyles — Can be tested without rendering, theme-switched, framework-agnostic
  • InvoiceCard — Simple coordinator, barely needs tests

DOU Mental Model: When you see complex logic in a component, ask: "Is this business logic, styling logic, or UI structure?" If it's business or styling logic, extract it to a domain object. Components should be boring.

Testing in DOU

// Test domain logic without UI
test('invoice calculates total correctly', () {
  final invoice = Invoice([
    InvoiceItem(price: 100, quantity: 2),
    InvoiceItem(price: 50, quantity: 1),
  ], DateTime.now());
  
  expect(invoice.subtotal, 250);
  expect(invoice.tax, 25);
  expect(invoice.total, 275);
});

// Test styling logic without rendering
test('overdue invoices get danger badge', () {
  final styles = InvoiceStyles(
    status: InvoiceStatus.overdue,
    isOverdue: true,
  );
  
  expect(styles.badgeClasses, 'badge badge-danger');
});

// Component test (minimal, if needed)
test('invoice card renders total', () {
  final invoice = Invoice([...], DateTime.now());
  final card = InvoiceCard(invoice);
  
  final html = card.render().toString();
  expect(html, contains('Total: $275.00'));
});

Domain objects are easy to test. Components become thin enough that extensive testing is often unnecessary.

What Pulsar Is Not

Components are NOT Disposable Render Functions

// ❌ NOT Pulsar: Function called every render
Widget userProfile() {
  return Card([...]);
}

// ✅ Pulsar: Object created once
final class UserProfile extends Component {
  @override
  Morphic render() {
    return Div().classes("card")([...]);
  }
}

State is NOT Derived from Hooks or Hidden Stores

// ❌ NOT Pulsar: Hooks
Widget counter() {
  final count = useState(0);
  return Button(onPressed: () => count.value++);
}

// ✅ Pulsar: Fields
final class Counter extends Component {
  int count = 0;
  
  void increment(Event e) {
    morph(() => count++);
  }
}

UI is NOT Rebuilt from Scratch on Every Change

Currently, Pulsar rebuilds the Morphic tree from root, but components themselves are NOT recreated. Future versions will optimize to partial tree updates, but the mental model remains: components are long-lived objects.

Identity is NOT Inferred by Heuristics

// ❌ NOT Pulsar: Identity inferred by position/key
Widget build() {
  return Column([
    UserCard(key: 'user-1'),  // Framework infers identity via key
    UserCard(key: 'user-2'),
  ]);
}

// ✅ Pulsar: Identity explicit via ownership
final class Page extends Component {
  final UserCard user1 = UserCard();  // Explicit
  final UserCard user2 = UserCard();  // Explicit

  @override
  Morphic render() {
    return Div()([user1, user2]);
  }
}

Mental Model Summary

To work effectively with Pulsar, internalize these core concepts:

  • Components are objects — Created once, kept alive, evolved over time
  • Ownership is explicit — Store components as fields to preserve identity
  • render() is pure — It describes structure, does not create it
  • morph() updates state — Explicit, deliberate state changes
  • Domain objects isolate logic — Business and styling logic live outside components
  • Components coordinate — They connect domain objects to UI structure

Key Takeaway: If you treat Pulsar components as real objects with explicit ownership and identity, and use domain objects to isolate logic, the framework becomes predictable, simple, and mentally lightweight. Everything else follows from these foundations.