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 callingmorph()to prevent errors. - Transform to Morphic early: Convert API data to Morphic nodes in the
morph()callback, NEVER inrender(). - 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 inrender(). - Reusability:
CoffeeLoadercan 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 keeprender()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 callingmorph()in async methods. - Transform to Morphic early — Convert data to
Morphicnodes inmorph()or getters, not inrender(). - 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 inrender() - ❌ Calling
morph()without checkingif (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.