Skip to content

State Management

View raw .md
On this page

Introduction#

Magic Starter uses a three-part state management model built on the Magic Framework:

  1. MagicController — base class that provides lifecycle management and ties into the IoC container.
  2. MagicStateMixin<T> — mixin that adds a four-state machine (loading, success, error, empty) with setLoading(), setSuccess(), setError(), clearErrors(), and renderState() helpers.
  3. IoC container — Magic.findOrPut() returns the existing singleton for a class or registers and returns a new one. Views never construct controllers directly.

Consumer app controllers follow the exact same pattern as magic_starter's own controllers. You extend MagicController, mix in MagicStateMixin<T>, and expose a static get instance accessor backed by Magic.findOrPut(). Views bind to that accessor and call renderState() to drive conditional rendering.

Creating a State Class#

Extend MagicController#

Declare your controller in a file named after the feature it manages:

dart
import 'package:magic/magic.dart';

class ProjectController extends MagicController
    with MagicStateMixin<List<Map<String, dynamic>>> {
  // ...
}

The generic type parameter on MagicStateMixin<T> is the value type passed to setSuccess(value). Use List<Map<String, dynamic>> for collections, bool for form-submission controllers, or a typed model class for single-resource controllers.

Add the Singleton Accessor#

Every controller exposes a static instance getter backed by Magic.findOrPut():

dart
class ProjectController extends MagicController
    with MagicStateMixin<List<Map<String, dynamic>>> {
  static ProjectController get instance =>
      Magic.findOrPut(ProjectController.new);
}

Magic.findOrPut(ProjectController.new) checks whether a ProjectController is already registered in the container. If it is, it returns the existing instance. If not, it calls ProjectController.new (the tear-off constructor), registers it as a singleton, and returns it. All views calling ProjectController.instance will receive the same object.

Implement Async Methods#

Each async action follows the same lifecycle:

  1. Guard against re-entrant calls with a _isLoading flag.
  2. Call setLoading() to transition the state machine.
  3. Perform the HTTP call via Http.get() / Http.post() / etc.
  4. Call setSuccess(value) on the happy path or setError(message) on failure.
  5. Reset the guard in finally.
dart
class ProjectController extends MagicController
    with MagicStateMixin<List<Map<String, dynamic>>> {
  // -------------------------------------------------------------------------
  // Singleton
  // -------------------------------------------------------------------------

  static ProjectController get instance =>
      Magic.findOrPut(ProjectController.new);

  // -------------------------------------------------------------------------
  // State
  // -------------------------------------------------------------------------

  bool _isLoading = false;

  // -------------------------------------------------------------------------
  // Actions
  // -------------------------------------------------------------------------

  /// Fetch all projects from the API and expose them via state machine.
  Future<void> loadProjects() async {
    if (_isLoading) return;
    _isLoading = true;
    setLoading();

    try {
      final response = await Http.get('/api/projects');

      if (!response.successful) {
        setError('Failed to load projects.');
        return;
      }

      final data = response.data['data'];
      if (data is List) {
        setSuccess(data.cast<Map<String, dynamic>>());
      } else {
        setSuccess([]);
      }
    } catch (e, stackTrace) {
      Log.error('[ProjectController.loadProjects] $e\n$stackTrace');
      setError('An unexpected error occurred.');
    } finally {
      _isLoading = false;
    }
  }
}

The MagicStateMixin state transitions and the properties they expose:

Method Resulting state View properties set
setLoading() loading isLoading == true
setSuccess(value) success isLoading == false, state value available
setError(message) error hasErrors == true, error message stored
clearErrors() (no transition) Clears stored error messages
setEmpty() empty isEmpty == true

Registration Decision Tree#

Use this decision tree to determine how and where to register your controller:

text
Does the controller need to be ready before any view renders?
│
├─ YES → Register eagerly in AppServiceProvider.boot()
│        Magic.findOrPut(ProjectController.new);
│        Views resolve via ProjectController.instance (same singleton)
│
└─ NO  → Use lazy findOrPut() (default pattern)
         static ProjectController get instance =>
             Magic.findOrPut(ProjectController.new);
         First view access triggers registration automatically

         Is the controller shared across multiple views?
         │
         ├─ YES → findOrPut() — returns the same singleton everywhere
         │
         └─ NO  → Consider a plain StatefulWidget with local ChangeNotifier
                  (no IoC involvement needed for purely local state)
Scenario Registration approach Example
Global nav data (unread count, current team) Eager — AppServiceProvider.boot() NotificationController pre-warmed on login
Feature-specific data (project list, report) Lazy — findOrPut() ProjectController only loaded when the projects screen mounts
Single-view transient state (modal open/close) Local ChangeNotifier in StatefulWidget No IoC — no need for MagicController

Connecting State to Views#

Pattern A: MagicStatefulView#

Use MagicStatefulView<ControllerType> for all full-page views. The base class resolves controller automatically via instance and listens to state changes.

dart
import 'package:material_ui/material_ui.dart';
import 'package:magic/magic.dart';
import 'package:magic_starter/magic_starter.dart';

class ProjectListView extends MagicStatefulView<ProjectController> {
  const ProjectListView({super.key});

  @override
  State<ProjectListView> createState() => _ProjectListViewState();
}

class _ProjectListViewState
    extends MagicStatefulViewState<ProjectController, ProjectListView> {
  @override
  void onInit() {
    controller.loadProjects();
  }

  @override
  Widget build(BuildContext context) {
    return controller.renderState(
      (projects) => _buildList(projects),
      onEmpty: _buildEmpty(),
      onError: (message) => _buildError(message),
    );
  }

  Widget _buildList(List<Map<String, dynamic>> projects) {
    final isLoading = controller.isLoading;

    return WDiv(
      className: 'flex flex-col',
      children: [
        MSPageHeader(
          title: 'Projects',
          subtitle: 'Manage your projects',
          actions: [
            WButton(
              onTap: isLoading ? null : controller.loadProjects,
              className: 'py-2 px-4 rounded-lg bg-primary text-white text-sm',
              child: WText('Refresh'),
            ),
          ],
        ),
        WDiv(
          className: 'flex flex-col gap-2 p-6',
          children: [
            for (final project in projects)
              MSCard(
                child: WText(
                  project['name'] as String,
                  className: 'text-gray-900 dark:text-white text-sm',
                ),
              ),
          ],
        ),
      ],
    );
  }

  Widget _buildEmpty() {
    return WDiv(
      className: 'flex flex-col items-center justify-center p-12',
      children: [
        WText(
          'No projects yet.',
          className: 'text-gray-500 dark:text-gray-400 text-sm',
        ),
      ],
    );
  }

  Widget _buildError(String message) {
    return WDiv(
      className: 'flex flex-col items-center justify-center p-12',
      children: [
        WText(message, className: 'text-sm text-red-500'),
      ],
    );
  }
}

The renderState() method selects the builder based on the current MagicStateMixin state:

Argument When rendered
First positional (T value) => Widget Controller called setSuccess(value)
onEmpty: Controller called setEmpty() or state is initial
onError: (String message) => Widget Controller called setError(message)

While the state is loading, renderState() displays a built-in loading indicator — no onLoading builder is needed.

Pattern B: StatefulWidget + Manual Access#

Use a plain StatefulWidget with ProjectController.instance when you need controller access inside a non-page widget (e.g., a dropdown, a card action, or a sub-widget inside an existing view):

dart
import 'package:material_ui/material_ui.dart';
import 'package:magic/magic.dart';
import 'package:magic_starter/magic_starter.dart';

class ProjectCountBadge extends StatefulWidget {
  const ProjectCountBadge({super.key});

  @override
  State<ProjectCountBadge> createState() => _ProjectCountBadgeState();
}

class _ProjectCountBadgeState extends State<ProjectCountBadge> {
  late final ProjectController _controller;

  @override
  void initState() {
    super.initState();
    _controller = ProjectController.instance;
    _controller.addListener(_onStateChanged);
  }

  @override
  void dispose() {
    _controller.removeListener(_onStateChanged);
    super.dispose();
  }

  void _onStateChanged() => setState(() {});

  @override
  Widget build(BuildContext context) {
    final count = _controller.isSuccess
        ? (_controller.state as List).length
        : 0;

    return WDiv(
      className: 'flex items-center gap-1',
      children: [
        WIcon(Icons.folder_outlined, className: 'text-gray-500 text-base'),
        WText(
          '$count Projects',
          className: 'text-sm text-gray-700 dark:text-gray-300',
        ),
      ],
    );
  }
}

Testing#

Tests follow the same pattern as magic_starter's own controller tests: reset the IoC container in setUp, bind a MockNetworkDriver, and assert state transitions.

dart
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
import 'package:magic_starter/magic_starter.dart';

// ---------------------------------------------------------------------------
// Mock
// ---------------------------------------------------------------------------

class MockNetworkDriver implements NetworkDriver {
  MagicResponse? nextResponse;

  String? lastMethod;
  String? lastUrl;
  dynamic lastData;

  void mockResponse({required int statusCode, dynamic data}) {
    nextResponse = MagicResponse(
      data: data ?? {},
      statusCode: statusCode,
    );
  }

  MagicResponse _respond(String method, String url, {dynamic data}) {
    lastMethod = method;
    lastUrl = url;
    lastData = data;
    return nextResponse ?? MagicResponse(data: {}, statusCode: 500);
  }

  @override
  void addInterceptor(MagicNetworkInterceptor interceptor) {}

  @override
  Future<MagicResponse> get(
    String url, {
    Map<String, dynamic>? query,
    Map<String, String>? headers,
  }) async =>
      _respond('GET', url);

  @override
  Future<MagicResponse> post(
    String url, {
    dynamic data,
    Map<String, String>? headers,
  }) async =>
      _respond('POST', url, data: data);

  @override
  Future<MagicResponse> put(
    String url, {
    dynamic data,
    Map<String, String>? headers,
  }) async =>
      _respond('PUT', url, data: data);

  @override
  Future<MagicResponse> delete(
    String url, {
    Map<String, String>? headers,
  }) async =>
      _respond('DELETE', url);

  @override
  Future<MagicResponse> index(
    String resource, {
    Map<String, dynamic>? filters,
    Map<String, String>? headers,
  }) async =>
      _respond('INDEX', resource);

  @override
  Future<MagicResponse> show(
    String resource,
    String id, {
    Map<String, String>? headers,
  }) async =>
      _respond('SHOW', '$resource/$id');

  @override
  Future<MagicResponse> store(
    String resource,
    Map<String, dynamic> data, {
    Map<String, String>? headers,
  }) async =>
      _respond('STORE', resource, data: data);

  @override
  Future<MagicResponse> update(
    String resource,
    String id,
    Map<String, dynamic> data, {
    Map<String, String>? headers,
  }) async =>
      _respond('UPDATE', '$resource/$id', data: data);

  @override
  Future<MagicResponse> destroy(
    String resource,
    String id, {
    Map<String, String>? headers,
  }) async =>
      _respond('DESTROY', '$resource/$id');

  @override
  Future<MagicResponse> upload(
    String url, {
    required Map<String, dynamic> data,
    required Map<String, dynamic> files,
    Map<String, String>? headers,
  }) async =>
      _respond('UPLOAD', url, data: data);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

void main() {
  group('ProjectController', () {
    late MockNetworkDriver mockDriver;
    late ProjectController controller;

    setUp(() {
      MagicApp.reset();
      Magic.flush();

      Magic.singleton('network', () => MockNetworkDriver());
      Magic.singleton('log', () => LogManager());
      Config.set('logging', {
        'default': 'console',
        'channels': {
          'console': {'driver': 'console', 'level': 'debug'},
        },
      });

      controller = ProjectController();
      mockDriver = Magic.make<NetworkDriver>('network') as MockNetworkDriver;
    });

    tearDown(() {
      controller.dispose();
      Auth.manager.forgetGuards();
    });

    test('loadProjects success — state transitions to success', () async {
      mockDriver.mockResponse(
        statusCode: 200,
        data: {
          'data': [
            {'id': 1, 'name': 'Alpha'},
            {'id': 2, 'name': 'Beta'},
          ],
        },
      );

      await controller.loadProjects();

      expect(mockDriver.lastMethod, equals('GET'));
      expect(mockDriver.lastUrl, equals('/api/projects'));
    });

    test('loadProjects error (500) — state transitions to error', () async {
      mockDriver.mockResponse(statusCode: 500);

      await controller.loadProjects();

      expect(controller.hasErrors, isTrue);
    });

    test('loadProjects is not re-entrant', () async {
      mockDriver.mockResponse(
        statusCode: 200,
        data: {'data': []},
      );

      final first = controller.loadProjects();
      final second = controller.loadProjects();
      await Future.wait([first, second]);

      // Only one GET should have been issued.
      expect(mockDriver.lastMethod, equals('GET'));
    });
  });
}

Complete Example#

End-to-end implementation of a ProjectList feature — state class, view, and test.

State class (lib/src/http/controllers/project_controller.dart)#

dart
import 'package:magic/magic.dart';

class ProjectController extends MagicController
    with MagicStateMixin<List<Map<String, dynamic>>> {
  // -------------------------------------------------------------------------
  // Singleton
  // -------------------------------------------------------------------------

  static ProjectController get instance =>
      Magic.findOrPut(ProjectController.new);

  // -------------------------------------------------------------------------
  // Private
  // -------------------------------------------------------------------------

  bool _isLoading = false;

  // -------------------------------------------------------------------------
  // Actions
  // -------------------------------------------------------------------------

  /// Fetch all projects from the API.
  Future<void> loadProjects() async {
    if (_isLoading) return;
    _isLoading = true;
    setLoading();

    try {
      final response = await Http.get('/api/projects');

      if (!response.successful) {
        setError('Failed to load projects.');
        return;
      }

      final data = response.data['data'];
      setSuccess(
        data is List ? data.cast<Map<String, dynamic>>() : [],
      );
    } catch (e, stackTrace) {
      Log.error('[ProjectController.loadProjects] $e\n$stackTrace');
      setError('An unexpected error occurred.');
    } finally {
      _isLoading = false;
    }
  }
}

View (lib/src/ui/views/projects/project_list_view.dart)#

dart
import 'package:material_ui/material_ui.dart';
import 'package:magic/magic.dart';
import 'package:magic_starter/magic_starter.dart';

class ProjectListView extends MagicStatefulView<ProjectController> {
  const ProjectListView({super.key});

  @override
  State<ProjectListView> createState() => _ProjectListViewState();
}

class _ProjectListViewState
    extends MagicStatefulViewState<ProjectController, ProjectListView> {
  @override
  void onInit() {
    controller.loadProjects();
  }

  @override
  Widget build(BuildContext context) {
    return controller.renderState(
      (projects) => _buildList(projects),
      onEmpty: _buildEmpty(),
      onError: (message) => _buildError(message),
    );
  }

  // -------------------------------------------------------------------------
  // Private builders
  // -------------------------------------------------------------------------

  Widget _buildList(List<Map<String, dynamic>> projects) {
    final isLoading = controller.isLoading;

    return WDiv(
      className: 'flex flex-col',
      children: [
        MSPageHeader(
          title: 'Projects',
          subtitle: '${projects.length} total',
          actions: [
            WButton(
              onTap: isLoading ? null : controller.loadProjects,
              className: 'py-2 px-4 rounded-lg bg-primary text-white text-sm',
              child: WText('Refresh'),
            ),
          ],
        ),
        WDiv(
          className: 'flex flex-col gap-3 p-6',
          children: [
            for (final project in projects)
              MSCard(
                child: WDiv(
                  className: 'flex flex-col gap-1',
                  children: [
                    WText(
                      project['name'] as String,
                      className: 'text-sm font-medium text-gray-900 dark:text-white',
                    ),
                    if (project['description'] != null)
                      WText(
                        project['description'] as String,
                        className: 'text-xs text-gray-500 dark:text-gray-400',
                      ),
                  ],
                ),
              ),
          ],
        ),
      ],
    );
  }

  Widget _buildEmpty() {
    return WDiv(
      className: 'flex flex-col items-center justify-center p-12',
      children: [
        WIcon(Icons.folder_outlined, className: 'text-4xl text-gray-300 dark:text-gray-600'),
        WSpacer(className: 'h-4'),
        WText(
          'No projects yet.',
          className: 'text-sm text-gray-500 dark:text-gray-400',
        ),
      ],
    );
  }

  Widget _buildError(String message) {
    return WDiv(
      className: 'flex flex-col items-center justify-center p-12',
      children: [
        WText(message, className: 'text-sm text-red-500 dark:text-red-400'),
      ],
    );
  }
}

Test (test/http/controllers/project_controller_test.dart)#

dart
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
import 'package:magic_starter/magic_starter.dart';

class MockNetworkDriver implements NetworkDriver {
  MagicResponse? nextResponse;
  String? lastMethod;
  String? lastUrl;
  dynamic lastData;

  void mockResponse({required int statusCode, dynamic data}) {
    nextResponse = MagicResponse(data: data ?? {}, statusCode: statusCode);
  }

  MagicResponse _respond(String method, String url, {dynamic data}) {
    lastMethod = method;
    lastUrl = url;
    lastData = data;
    return nextResponse ?? MagicResponse(data: {}, statusCode: 500);
  }

  @override
  void addInterceptor(MagicNetworkInterceptor interceptor) {}

  @override
  Future<MagicResponse> get(
    String url, {
    Map<String, dynamic>? query,
    Map<String, String>? headers,
  }) async =>
      _respond('GET', url);

  @override
  Future<MagicResponse> post(
    String url, {
    dynamic data,
    Map<String, String>? headers,
  }) async =>
      _respond('POST', url, data: data);

  @override
  Future<MagicResponse> put(
    String url, {
    dynamic data,
    Map<String, String>? headers,
  }) async =>
      _respond('PUT', url, data: data);

  @override
  Future<MagicResponse> delete(
    String url, {
    Map<String, String>? headers,
  }) async =>
      _respond('DELETE', url);

  @override
  Future<MagicResponse> index(
    String resource, {
    Map<String, dynamic>? filters,
    Map<String, String>? headers,
  }) async =>
      _respond('INDEX', resource);

  @override
  Future<MagicResponse> show(
    String resource,
    String id, {
    Map<String, String>? headers,
  }) async =>
      _respond('SHOW', '$resource/$id');

  @override
  Future<MagicResponse> store(
    String resource,
    Map<String, dynamic> data, {
    Map<String, String>? headers,
  }) async =>
      _respond('STORE', resource, data: data);

  @override
  Future<MagicResponse> update(
    String resource,
    String id,
    Map<String, dynamic> data, {
    Map<String, String>? headers,
  }) async =>
      _respond('UPDATE', '$resource/$id', data: data);

  @override
  Future<MagicResponse> destroy(
    String resource,
    String id, {
    Map<String, String>? headers,
  }) async =>
      _respond('DESTROY', '$resource/$id');

  @override
  Future<MagicResponse> upload(
    String url, {
    required Map<String, dynamic> data,
    required Map<String, dynamic> files,
    Map<String, String>? headers,
  }) async =>
      _respond('UPLOAD', url, data: data);
}

void main() {
  group('ProjectController', () {
    late MockNetworkDriver mockDriver;
    late ProjectController controller;

    setUp(() {
      MagicApp.reset();
      Magic.flush();

      Magic.singleton('network', () => MockNetworkDriver());
      Magic.singleton('log', () => LogManager());
      Config.set('logging', {
        'default': 'console',
        'channels': {
          'console': {'driver': 'console', 'level': 'debug'},
        },
      });

      controller = ProjectController();
      mockDriver = Magic.make<NetworkDriver>('network') as MockNetworkDriver;
    });

    tearDown(() {
      controller.dispose();
      Auth.manager.forgetGuards();
    });

    test('loadProjects success — hits correct endpoint', () async {
      mockDriver.mockResponse(
        statusCode: 200,
        data: {
          'data': [
            {'id': 1, 'name': 'Alpha'},
            {'id': 2, 'name': 'Beta'},
          ],
        },
      );

      await controller.loadProjects();

      expect(mockDriver.lastMethod, equals('GET'));
      expect(mockDriver.lastUrl, equals('/api/projects'));
      expect(controller.hasErrors, isFalse);
    });

    test('loadProjects 500 — transitions to error state', () async {
      mockDriver.mockResponse(statusCode: 500);

      await controller.loadProjects();

      expect(controller.hasErrors, isTrue);
    });

    test('loadProjects re-entrant guard — only one GET issued', () async {
      mockDriver.mockResponse(
        statusCode: 200,
        data: {'data': []},
      );

      final first = controller.loadProjects();
      final second = controller.loadProjects();
      await Future.wait([first, second]);

      expect(mockDriver.lastUrl, equals('/api/projects'));
    });
  });
}
  • Controllers — full HTTP controller reference with error handling patterns
  • Service Providers — two-phase bootstrap and eager controller registration in boot()
  • Views and Layouts — MagicStatefulView lifecycle, renderState(), and form handling
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.