Components
How to think components in Pulsar
Components
Components are long-lived objects that encapsulate state, behavior and rendering logic. In Pulsar, components are not recreated on every update: they persist, evolve and morph their output over time.
A component is an object with identity. It is created once, kept alive, and updated through explicit state mutations. This is fundamentally different from frameworks where components are disposable functions recreated on each render.
Defining a Component
A component in Pulsar is a class that extends Component. It holds mutable state, defines behavior through methods, and produces a UI tree through its render method.
final class Counter extends Component {
int count = 0;
void increment(Event event) {
morph(() => count++);
}
@override
Morphic render() {
return Button().onClick(increment)(['Count: $count']);
}
}
The Counter component is created once. When increment() is called, the component morphs its output to reflect the new count. The same object persists throughout.
Component Structure
Every component has three responsibilities:
- State — Fields that persist across renders
- Behavior — Methods that mutate state via morph()
- Rendering — Pure description of UI structure via render()
final class TodoApp extends Component {
// 1. STATE - Persists across renders
List<String> todos = [];
String inputValue = '';
// 2. BEHAVIOR - Mutates state via morph()
void addTodo(Event e) {
if (inputValue.trim().isEmpty) return;
morph(() {
todos.add(inputValue);
inputValue = '';
});
}
void updateInput(Event e) {
final target = e.target as InputElement;
morph(() => inputValue = target.value ?? '');
}
// Getter for transformations
List<Morphic> get todoElements =>
todos.map((todo) => Li()([todo])).toList();
// 3. RENDERING - Pure description
@override
Morphic render() {
return Div().classes("todo-app")([
H2()(['My Todos']),
Input()
.value(inputValue)
.onInput(updateInput)
.placeholder('Enter todo'),
Button().onClick(addTodo)(['Add']),
Ul()([todoElements]),
]);
}
}
Using a Component
Components are used by creating an instance and including it in the UI tree. Once mounted, the same instance remains alive and continues to evolve as its internal state changes.
final class HomePage extends Component {
// Store component as field - preserves identity
final Counter counter = Counter();
@override
Morphic render() {
return Div().classes("home-page")([
H1()(['Welcome']),
counter, // Component inserted directly
]);
}
}
The counter is stored as a field on HomePage. This preserves its identity — the same Counter instance persists even when HomePage re-renders.
Component Identity
In Pulsar, component identity is explicit. A component instance must have a clear owner that determines its lifetime.
Identity is Preserved When:
- Components are stored as fields
- Components are passed as constructor parameters
- Components are managed by the Router
- Components are owned by a parent component
Identity is BROKEN When:
- Components are created inside render()
- Components are instantiated in Router builders
- Components are recreated on every parent update
// CORRECT - Identity preserved
final class Dashboard extends Component {
final StatsWidget stats = StatsWidget();
final ChartWidget chart = ChartWidget();
@override
Morphic render() {
return Div().classes("dashboard")([
stats, // Same instance always
chart, // Same instance always
]);
}
}
// WRONG - Identity broken on every render
final class Dashboard extends Component {
@override
Morphic render() {
return Div().classes("dashboard")([
StatsWidget(), // NEW instance every render!
ChartWidget(), // NEW instance every render!
]);
}
}
Creating components inside render() breaks identity. Each render creates new instances, discarding state and breaking the morphic model.
Critical Rule: If a component needs to preserve state or identity, it MUST be owned outside of render(). Store it as a field, pass it as a parameter, or let the Router manage it.
Understanding render()
The render() method describes what the UI should look like at a given point in time. It is a pure description of structure, not executable logic.
render() is Pure Description
final class UserList extends Component {
List<User> users = [];
// Getter - transforms outside render
List<Morphic> get userElements =>
users.map((user) => UserCard(user)).toList();
@override
Morphic render() {
// Pure - just describes structure
return Div().classes("user-list")([
H2()(['Users']),
Div().classes("grid")([...userElements]),
]);
}
}
All transformation happens in the getter. render() simply describes: "Show a div with a heading and a grid of user elements."
What render() Does NOT Do
- ❌ Does not transform data (use getters)
- ❌ Does not mutate state (use morph())
- ❌ Does not perform side effects (use onMorph())
- ❌ Does not create component instances (store as fields)
- ❌ Does not contain business logic (use methods)
Remember: When state changes via morph(), Pulsar re-invokes render() and morphs the DOM to match the new output. The component instance remains alive — only its rendered structure changes.
Conditional Rendering
Conditional rendering is achieved using standard Dart control flow. No special directives or hidden behaviors.
final class DataView extends Component {
bool loading = true;
String? error;
List<Morphic> data = [];
DataView() {
_loadData();
}
Future<void> _loadData() async {
final result = await fetchData();
if (attached) {
morph(() {
data = result.map((item) => Li()([item])).toList();
loading = false;
});
}
}
@override
Morphic render() {
// If/else - structural control flow
if (loading) {
return Div().classes("loading")(['Loading data...']);
}
if (error != null) {
return Div().classes("error")(['Error: $error']);
}
return Div().classes("data-view")([
H2()(['Data Loaded']),
Ul()([data]),
]);
}
}
The component morphs between different UI states without losing identity. It goes from loading → error or loading → data, but the DataView instance remains the same object throughout.
Passing Data to Components
Data flows into components through constructor parameters. This makes dependencies explicit and clear.
final class UserCard extends Component {
final User user;
final void Function(String) onViewProfile;
UserCard(this.user, {required this.onViewProfile});
void handleClick(Event e) {
onViewProfile(user.id);
}
@override
Morphic render() {
return Div().classes("user-card")([
H3()([user.name]),
P()([user.email]),
Button().onClick(handleClick)(['View Profile']),
]);
}
}
UserCard receives user data and a callback through its constructor. This is explicit — you can see exactly what the component depends on.
Using UserCard
final class UserList extends Component {
List<User> users = [];
void viewProfile(String userId) {
navigateTo('/users/$userId');
}
// Getter transforms data with callbacks
List<Morphic> get userCards =>
users.map((user) => UserCard(
user,
onViewProfile: viewProfile,
)).toList();
@override
Morphic render() {
return Div().classes("user-list")([userCards]);
}
}
Stateful vs Stateless Components
In Pulsar, there is no structural difference between "stateful" and "stateless" components. Both extend Component. The distinction is purely conceptual.
Stateful Component - Owns Mutable State
final class Counter extends Component {
int count = 0; // Mutable state
void increment(Event e) {
morph(() => count++);
}
@override
Morphic render() {
return Button().onClick(increment)(['Count: $count']);
}
}
Stateless Component - Receives Data
final class Label extends Component {
final String text;
final String variant;
Label(this.text, {this.variant = 'default'});
@override
Morphic render() {
return Span().classes('label label-$variant')([text]);
}
}
Label is "stateless" because it doesn't own mutable state. But it's still a Component — still long-lived, still has identity.
Important: Even "stateless" components in Pulsar are persistent objects with identity. They are not recreated on every render. The term "stateless" just means they receive data rather than owning mutable state.
Composition
Components compose naturally by storing other components as fields:
final class ProfilePage extends Component {
final String userId;
// Composed components
final ProfileHeader header;
final ProfileStats stats;
final ActivityFeed feed;
ProfilePage(this.userId)
: header = ProfileHeader(userId),
stats = ProfileStats(userId),
feed = ActivityFeed(userId);
@override
Morphic render() {
return Div().classes("profile-page")([
header,
stats,
feed,
]);
}
}
Each composed component maintains its own state and identity. ProfilePage orchestrates them but doesn't own their state.
Component Communication
Components communicate through explicit patterns:
Parent → Child: Constructor Parameters
final class Parent extends Component {
int count = 0;
@override
Morphic render() {
return ChildDisplay(count); // Pass data down
}
}
final class ChildDisplay extends Component {
final int value;
ChildDisplay(this.value);
@override
Morphic render() {
return P()(['Value: $value']);
}
}
Child → Parent: Callback Functions
final class Parent extends Component {
int count = 0;
void increment(Event e) {
morph(() => count++);
}
@override
Morphic render() {
return ChildButton(onPressed: increment); // Pass callback
}
}
final class ChildButton extends Component {
final void Function(Event) onPressed;
ChildButton({required this.onPressed});
@override
Morphic render() {
return Button().onClick(onPressed)(['Increment']);
}
}
This pattern keeps data flow explicit and unidirectional:
- Data flows down through constructor parameters
- Events flow up through callbacks
- State ownership is always clear
- No hidden subscriptions or observers
Advanced: Components with Public Methods
Components can expose public methods for external control:
final class MediaPlayer extends Component {
bool playing = false;
double currentTime = 0.0;
// Public API
void play() {
morph(() => playing = true);
}
void pause() {
morph(() => playing = false);
}
void seekTo(double time) {
morph(() => currentTime = time);
}
@override
Morphic render() {
return Div().classes("media-player")([
if (playing)
P()(['Playing at ${currentTime}s'])
else
P()(['Paused']),
]);
}
}
// Usage
final class PlayerPage extends Component {
final MediaPlayer player = MediaPlayer();
void handlePlayClick(Event e) {
player.play(); // Call component method directly
}
void handlePauseClick(Event e) {
player.pause();
}
@override
Morphic render() {
return Div()([
player,
Button().onClick(handlePlayClick)(['Play']),
Button().onClick(handlePauseClick)(['Pause']),
]);
}
}
This pattern is useful when parent components need fine-grained control over child components without passing callbacks for every operation.
Best Practices
- Store components as fields — Preserve identity and state.
- Use getters for transformations — Keep render() pure.
- Make dependencies explicit — Pass data through constructors.
- Keep components focused — Single responsibility per component.
- Use composition over complexity — Break large components into smaller ones.
- Document public methods — Make component APIs clear.
Anti-Patterns
- ❌ Creating components inside render()
- ❌ Using map() or transformations in render()
- ❌ Mutating state outside of morph()
- ❌ Hiding dependencies (implicit global state)
- ❌ Creating massive components with too many responsibilities
- ❌ Expecting components to be recreated during updates
Core Truth: Components in Pulsar are objects with identity, not functions. They are created once, evolve through state changes, and persist across renders. If you treat components as long-lived objects with explicit ownership, Pulsar becomes predictable and mentally lightweight.