Morphic Components
Long lived components that morph over time and keep identity.
Morphic Components
"Morphic" describes semantic versatility, not a performance technique. In Pulsar, components can serve multiple roles without artificial hierarchies or type distinctions.
There is no distinction between "components", "pages", "layouts", or "widgets". Everything is a Component. The difference is purely semantic — it exists in how you think about and use the component, not in the framework.
What Does "Morphic" Mean?
"Morphic" means a component can adapt to different roles and contexts without losing its identity. The same component object can be a page in the router, a widget in a sidebar, a modal in an overlay, or a layout wrapping other components.
This is semantic morphing: the component's purpose changes based on context, but the framework treats it the same way. There are no special base classes for pages vs widgets — you decide what role a component plays through composition.
Key insight: Morphic is about semantic flexibility without framework magic. A component morphs between roles naturally because everything is just a Component.
The Technical Reality
When a component's state changes via morph(), the runtime re-invokes render() and updates the DOM to reflect the new output. The component instance itself remains alive — only its rendered structure changes.
This means:
- Component instances are created once and kept alive
- State persists across renders
- Identity is preserved during navigation and updates
- The DOM morphs to match the new output, not rebuilt from scratch
But this is an implementation detail. What matters philosophically is that components are long-lived objects, not ephemeral render functions.
Simple Morphic Example
A basic component demonstrates morphic behavior through state changes:
final class Counter extends Component {
int count = 0;
void increment(Event e) {
morph(() => count++);
}
@override
Morphic render() {
return Div()([
Span()(['Count: $count']),
Button().onClick(increment)(['Increment']),
]);
}
}
The Counter component is never recreated. When increment() is called, only its rendered output changes. The object itself persists.
Semantic Morphing in Practice
The real power of morphic components is semantic: the same component can serve different purposes based on how it's used.
final class UserProfile extends Component {
String userId;
DisplayMode mode; // full, compact, modal
UserProfile(this.userId, {this.mode = DisplayMode.full});
// Morph to show different user
void showUser(String newId) {
morph(() => userId = newId);
}
// Morph to different presentation
void switchMode(DisplayMode newMode) {
morph(() => mode = newMode);
}
@override
Morphic render() {
return switch (mode) {
DisplayMode.full => _renderFullProfile(),
DisplayMode.compact => _renderCard(),
DisplayMode.modal => _renderModal(),
};
}
Morphic _renderFullProfile() {
return Article().classes("profile-full")([
H1()(['User Profile']),
Div().classes("profile-details")([
// Full profile content
]),
]);
}
Morphic _renderCard() {
return Div().classes("profile-card")([
// Compact card content
]);
}
Morphic _renderModal() {
return Div().classes("modal")([
// Modal content
]);
}
}
The same UserProfile object morphs between:
- Different users (userId changes)
- Different presentations (mode changes)
- Different contexts (page vs widget vs modal)
Without being destroyed and recreated. This is semantic morphing.
Layouts as Morphic Components
Layouts are just components. They are long-lived and wrap changing content. There is no "Layout" base class — they are semantically layouts, but structurally just Component.
final class AppLayout extends Component {
final Component page;
AppLayout(this.page);
@override
Morphic render() {
return Div().classes("layout")([
Header()([
Nav().classes("navbar")([
A().href('/')(['Home']),
A().href('/docs')(['Docs']),
]),
]),
Main().classes("content")([
page, // Page morphs, layout persists
]),
Footer()([
P()(['© 2026 Pulsar']),
]),
]);
}
}
The layout persists while the page it receives morphs. Navigation does not recreate the layout. Both are just Components — the semantic difference is that one wraps the other.
Routing as Morphic Components
Even the router is just a component. RouterComponent is not special — it's a Component that owns navigation logic.
void main() {
mountApp(
RouterComponent(
router: Router(
routes: [
Route(path: '/', builder: (_) => HomePage()),
Route(path: '/about', builder: (_) => AboutPage()),
],
notFound: () => NotFoundPage(),
),
layout: (page) => AppLayout(page),
),
);
}
RouterComponent is a Component. AppLayout is a Component. HomePage is a Component. The framework does not distinguish — you assign meaning through composition.
Nested Routing
Because routing is just components returning components, nested routing emerges naturally without special APIs.
final class DashboardPage extends Component {
late final Router dashboardRouter = Router(
routes: [
Route(path: '/dashboard/stats', builder: (_) => StatsPage()),
Route(path: '/dashboard/settings', builder: (_) => SettingsPage()),
],
notFound: () => DashboardHome(),
);
@override
Morphic render() {
return Main()([
RouterComponent(
router: dashboardRouter,
layout: (page) => DashboardLayout(page),
)
]);
}
}
DashboardPage returns a RouterComponent. No special nesting API — just a component returning another component that happens to be a router.
Components Can Morph Their Own Structure
A component can completely change its rendered output based on state:
final class AdaptiveView extends Component {
ViewType currentView = ViewType.list;
void switchView(ViewType newView) {
morph(() => currentView = newView);
}
@override
Morphic render() {
return Div().classes("adaptive-view")([
_viewControls(),
switch (currentView) {
ViewType.list => _renderList(),
ViewType.grid => _renderGrid(),
ViewType.table => _renderTable(),
},
]);
}
Morphic _viewControls() {
return Div().classes("view-controls")([
Button()
.onClick((_) => switchView(ViewType.list))
.classes(currentView == ViewType.list ? 'active' : '')(['List']),
Button()
.onClick((_) => switchView(ViewType.grid))
.classes(currentView == ViewType.grid ? 'active' : '')(['Grid']),
Button()
.onClick((_) => switchView(ViewType.table))
.classes(currentView == ViewType.table ? 'active' : '')(['Table']),
]);
}
Morphic _renderList() => Ul().classes("list-view")([/* ... */]);
Morphic _renderGrid() => Div().classes("grid-view")([/* ... */]);
Morphic _renderTable() => Table().classes("table-view")([/* ... */]);
}
The component radically changes its output, but the AdaptiveView instance remains. This is morphic behavior at work.
No Artificial Hierarchies
In many frameworks, you would see:
// Other frameworks
class MyPage extends Page { }
class MyLayout extends Layout { }
class MyWidget extends Widget { }
class MyComponent extends Component { }
Pulsar rejects this. Everything extends the same base:
// Pulsar
final class MyPage extends Component { }
final class MyLayout extends Component { }
final class MyWidget extends Component { }
final class MyComponent extends Component { }
The semantic difference between a "page" and a "widget" is how you use it, not what class it extends. This is liberating: a component designed as a widget can become a page tomorrow without changing its type.
What Morphic Is NOT
To be clear about what "morphic" does not mean in Pulsar:
- NOT a performance optimization: Morphic is not about dirty tracking, virtual DOM diffing, or minimal DOM updates. Those are implementation details that may come later.
- NOT a framework feature: There is no "morphic API" or special methods. Components are morphic by design because they are persistent objects.
- NOT about DOM morphing libraries: Pulsar does not use morphdom or similar libraries. The term "morphic" describes the architectural concept, not the implementation technique.
The Core Truth: Morphic Components is a purely semantic concept. It means components serve multiple roles without class hierarchies. The performance characteristics, DOM update strategies, and optimization techniques are separate concerns that Pulsar addresses pragmatically, not philosophically.
Why This Matters
The morphic component model creates several benefits:
- Conceptual simplicity: One concept (Component) instead of many (Page, Layout, Widget, View, Screen).
- Architectural flexibility: Components can change roles without refactoring their type hierarchy.
- Natural composition: Since everything is the same type, composition is straightforward and uniform.
- Predictable behavior: No hidden lifecycle differences between "page components" and "widget components".
Final Example: The Same Component, Multiple Roles
Here's the ultimate demonstration of semantic morphing:
final class Notification extends Component {
final String message;
final NotificationType type;
Notification(this.message, {this.type = NotificationType.info});
@override
Morphic render() {
return Div().classes("notification notification-${type.name}")([
Span().classes("icon")([_getIcon()]),
Span().classes("message")([message]),
]);
}
String _getIcon() {
return switch (type) {
NotificationType.info => 'ℹ️',
NotificationType.success => '',
NotificationType.warning => '⚠️',
NotificationType.error => '❌',
};
}
}
// Same component, used in three different contexts:
// 1. As a standalone page (error page)
final errorPage = Notification(
'Page not found',
type: NotificationType.error,
);
// 2. As a widget in a dashboard
final dashboardWidget = Notification(
'System healthy',
type: NotificationType.success,
);
// 3. As a toast notification
final toastNotification = Notification(
'Changes saved',
type: NotificationType.info,
);
One component. Three completely different semantic roles. Zero framework magic. This is the essence of morphic components in Pulsar.
Remember: In Pulsar, components do not enter and leave existence based on their role. They evolve. A component is a component, whether it's a page, a layout, a widget, or all three at different times. This semantic flexibility without structural complexity is what makes Pulsar's component model "morphic".