jaspr_elementary

MVVM architecture for Jaspr applications, inspired by Flutter Elementary.

Pub Version License: MIT

Overview

jaspr_elementary provides tools for separating business logic and UI in Jaspr applications, following the principles of the Flutter elementary package. It implements the MVVM (Model-View-ViewModel) architecture pattern, making it easy to build maintainable, testable, and scalable web applications.

Features

  • MVVM Architecture — Clean separation of business logic, presentation logic, and UI
  • Lifecycle Management — Automatic ComponentModel lifecycle management (init, update, dispose)
  • Reactive Agnostic — Use any state management approach (Streams, ChangeNotifier, Riverpod, etc.)
  • SSR Compatible — Works with Jaspr's server-side rendering
  • Flutter Elementary Compatible — Familiar API for developers migrating from Flutter
  • Zero Dependencies — Only depends on jaspr and meta
  • Testable — Easy to unit test ComponentModels and Models independently

Installation

Add this to your package's pubspec.yaml file:

dependencies:
  jaspr_elementary: ^0.3.0

Then run:

dart pub get

Quick Start

1. Create a Business Logic Model

import 'package:jaspr_elementary/jaspr_elementary.dart';

class CounterModel extends ElementaryModel {
  int _count = 0;
  int get count => _count;

  final _countController = StreamController<int>.broadcast();
  Stream<int> get countStream => _countController.stream;

  void increment() {
    _count++;
    _countController.add(_count);
  }

  void decrement() {
    _count--;
    _countController.add(_count);
  }

  @override
  void dispose() {
    _countController.close();
    super.dispose();
  }
}

2. Create a ComponentModel

You can use a concrete ComponentModel class directly (simpler):

class CounterComponentModel extends ComponentModel<CounterComponent, CounterModel> {
  CounterComponentModel(CounterModel model) : super(model);

  int get count => model.count;
  Stream<int> get countStream => model.countStream;

  void increment() => model.increment();
  void decrement() => model.decrement();
}

Or create an explicit interface for better documentation and testing:

abstract interface class ICounterComponentModel extends IComponentModel {
  int get count;
  Stream<int> get countStream;
  void increment();
  void decrement();
}

class CounterComponentModel extends ComponentModel<CounterComponent, CounterModel>
    implements ICounterComponentModel {
  CounterComponentModel(CounterModel model) : super(model);

  @override
  int get count => model.count;

  @override
  Stream<int> get countStream => model.countStream;

  @override
  void increment() => model.increment();

  @override
  void decrement() => model.decrement();
}

3. Create a Component

class CounterComponent extends ElementaryComponent<CounterComponentModel> {
  const CounterComponent({
    super.key,
    ComponentModelFactory cmFactory = counterComponentModelFactory,
  }) : super(cmFactory);

  @override
  Component build(CounterComponentModel cm) {
    return Component.element(
      tag: 'div',
      classes: 'counter-container',
      children: [
        StreamBuilder<int>(
          stream: cm.countStream,
          initialData: cm.count,
          builder: (context, snapshot) {
            return Component.element(
              tag: 'p',
              children: [Component.text('Count: ${snapshot.data}')],
            );
          },
        ),
        Component.element(
          tag: 'button',
          events: {'click': (_) => cm.increment()},
          children: [Component.text('+')],
        ),
        Component.element(
          tag: 'button',
          events: {'click': (_) => cm.decrement()},
          children: [Component.text('-')],
        ),
      ],
    );
  }
}

// Factory function
CounterComponentModel counterComponentModelFactory(BuildContext context) {
  return CounterComponentModel(CounterModel());
}

4. Use the Component in Your App

import 'package:jaspr/jaspr.dart';
import 'package:jaspr_elementary/jaspr_elementary.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessComponent {
  const MyApp({super.key});

  @override
  Component build(BuildContext context) {
    return Component.element(
      tag: 'body',
      children: [
        CounterComponent(),
      ],
    );
  }
}

Architecture

MVVM Pattern

┌─────────────────────────────────────────────────────────────┐
│                  Component (UI)                             │
│  - Describes how to render UI                               │
│  - Receives data from ComponentModel                        │
│  - Handles user interactions                                │
└─────────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────┐
│               ComponentModel (Presentation)                 │
│  - Manages presentation state                               │
│  - Handles user interactions                                │
│  - Connects Model with Component                            │
│  - Lifecycle: init → update → dispose                       │
└─────────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────┐
│                  ElementaryModel (Business Logic)           │
│  - Pure business logic                                      │
│  - No UI dependencies                                       │
│  - Testable independently                                   │
│  - Reports errors via handleError()                         │
└─────────────────────────────────────────────────────────────┘

Element Lifecycle

mount() → initComponentModel() → didChangeDependencies() → build()
                               ↓
                     didUpdateComponent() (on update)
                               ↓
                          dispose() (on unmount)
Method Called When Purpose
initComponentModel() Once, before first build Initialize state, load data
didChangeDependencies() After init, when inherited data changes React to inherited data
didUpdateComponent() When component configuration changes React to config changes
activate() When reinserted after deactivate Resume operations
deactivate() When temporarily removed from tree Pause operations
dispose() Once, when permanently removed Clean up resources

Usage Examples

Error Handling

class DataModel extends ElementaryModel {
  Future<void> fetchData() async {
    try {
      final data = await api.getData();
      // Process data...
    } catch (error, stackTrace) {
      handleError(error, stackTrace: stackTrace);
    }
  }
}

class DataComponentModel extends ComponentModel<DataComponent, DataModel> {
  @override
  void onErrorHandle(Object error) {
    super.onErrorHandle(error);
    // Show error to user (snackbar, dialog, etc.)
    print('Error: $error');
  }
}

Async Operations

class DataComponentModel extends ComponentModel<DataComponent, DataModel> {
  @override
  void initComponentModel() {
    super.initComponentModel();
    loadData();
  }

  Future<void> loadData() async {
    final data = await api.fetchData();
    if (isMounted) {
      // Safe to update state
      model.updateData(data);
    }
  }

  @override
  void dispose() {
    // Cancel pending operations
    super.dispose();
  }
}

Using with ChangeNotifier

class CounterComponentModel extends ComponentModel<CounterComponent, CounterModel>
    with ChangeNotifier {
  void increment() {
    model.increment();
    notifyListeners();
  }

  @override
  void dispose() {
    // Dispose ChangeNotifier
    super.dispose();
  }
}

// In component
ListenableBuilder(
  listenable: cm,
  builder: (context) {
    return Component.text('Count: ${cm.count}');
  },
)

Using with DI Container

// Using get_it
CounterComponentModel counterComponentModelFactory(BuildContext context) {
  final model = getIt<CounterModel>();
  return CounterComponentModel(model);
}

// Using Riverpod
CounterComponentModel counterComponentModelFactory(BuildContext context) {
  final model = ref.read(counterModelProvider);
  return CounterComponentModel(model);
}

Migration from Flutter Elementary

If you are familiar with the Flutter elementary package, here is the mapping:

Flutter Elementary Jaspr Elementary
Widget Component
StatefulWidget StatefulComponent
ElementaryWidget ElementaryComponent
WidgetModel ComponentModel
IWidgetModel IComponentModel
ElementaryModel ElementaryModel
WidgetModelFactory ComponentModelFactory
BuildContext BuildContext

Code Migration Example

Flutter:

class CounterWidget extends ElementaryWidget<CounterWidgetModel> {
  @override
  Widget build(CounterWidgetModel cm) {
    return Scaffold(
      body: Text('Count: ${cm.count}'),
    );
  }
}

Jaspr:

class CounterComponent extends ElementaryComponent<CounterComponentModel> {
  @override
  Component build(CounterComponentModel cm) {
    return Component.element(
      tag: 'div',
      children: [Component.text('Count: ${cm.count}')],
    );
  }
}

Best Practices

1. Keep Models Pure

ElementaryModel should not depend on UI frameworks:

// ✅ Good - Pure business logic
class CounterModel extends ElementaryModel {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
  }
}

// ❌ Bad - UI dependency
class CounterModel extends ElementaryModel {
  void increment() {
    // Don't access BuildContext or UI elements here
  }
}

2. Clean Up Resources

Always dispose of streams, subscriptions, and controllers:

class DataComponentModel extends ComponentModel<DataComponent, DataModel> {
  StreamSubscription? _subscription;

  @override
  void initComponentModel() {
    super.initComponentModel();
    _subscription = model.dataStream.listen((data) {
      // Process data
    });
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }
}

3. Use isMounted for Async Operations

class DataComponentModel extends ComponentModel<DataComponent, DataModel> {
  Future<void> loadData() async {
    final data = await api.fetchData();
    if (isMounted) {
      // Safe to update state
      model.updateData(data);
    }
  }
}

4. Use Interfaces for Large Projects

// Define explicit contract
abstract interface class IUserComponentModel extends IComponentModel {
  String get userName;
  bool get isLoading;
  Future<void> loadUser(String id);
}

// Implement contract
class UserComponentModel extends ComponentModel<UserComponent, UserModel>
    implements IUserComponentModel {
  // ...
}

// Use contract in component
class UserComponent extends ElementaryComponent<IUserComponentModel> {
  @override
  Component build(IUserComponentModel cm) {
    // ...
  }
}

5. Handle Errors Centrally

class ProductionErrorHandler implements ErrorHandler {
  @override
  void handleError(Object error, {StackTrace? stackTrace}) {
    // Send to monitoring service
    Sentry.captureException(error, stackTrace: stackTrace);
    // Log to file
    Logger.logError(error, stackTrace: stackTrace);
  }
}

class DataModel extends ElementaryModel {
  DataModel() : super(errorHandler: ProductionErrorHandler());
}

Testing

Companion package jaspr_elementary_test (in the same repository) will provide testComponentModel and related helpers, modelled after Flutter Elementary’s elementary_test.

Unit Test ComponentModel

void main() {
  test('CounterComponentModel increments count', () {
    final model = CounterModel();
    final cm = CounterComponentModel(model);

    expect(cm.count, equals(0));

    cm.increment();

    expect(cm.count, equals(1));
  });

  test('CounterComponentModel handles errors', () {
    final model = CounterModel();
    final cm = CounterComponentModel(model);
    cm.initComponentModel();

    Object? capturedError;
    cm.setupTestError((error) => capturedError = error);

    model.handleError(Exception('Test error'));

    expect(capturedError, isA<Exception>());
  });
}

Unit Test Model

void main() {
  test('CounterModel increments count', () {
    final model = CounterModel();

    expect(model.count, equals(0));

    model.increment();

    expect(model.count, equals(1));
  });

  test('CounterModel reports errors', () {
    final model = CounterModel();
    Object? capturedError;
    model.setupCmHandler((error) => capturedError = error);

    model.handleError(Exception('Test error'));

    expect(capturedError, isA<Exception>());
  });
}

Additional Resources

License

This package is licensed under the MIT License.

Libraries

jaspr_elementary
MVVM architecture for Jaspr applications.