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.