State Management
How to write a controller in your own app on the same pattern the starter's controllers use: MagicController, MagicStateMixin, a lazy instance accessor and a MagicStatefulView.
On this page
Introduction#
The model has three parts, all from the Magic framework:
MagicController: the base class. It extendsChangeNotifierand carries theonInit()andonClose()lifecycle.MagicStateMixin<T>: a four-state machine (loading, success, error, empty) driven bysetLoading(),setSuccess(),setError()andsetEmpty(), rendered withrenderState().- The container:
Magic.findOrPut()returns the existing instance of a controller type or creates and registers one.
dart run magic:dowel make:controller Project generates a controller of this shape; see the Magic CLI. Views bind to the controller's instance accessor and call renderState() to choose what to draw. How the starter applies the pattern is in Controllers.
Creating a State Class#
Extend MagicController#
import 'package:magic/magic.dart';
class ProjectController extends MagicController
with MagicStateMixin<List<Map<String, dynamic>>> {
// ...
}
The type parameter of MagicStateMixin<T> is the value passed to setSuccess(value): a list for collections, bool for form submissions, a model class for a single resource.
Add the Singleton Accessor#
class ProjectController extends MagicController
with MagicStateMixin<List<Map<String, dynamic>>> {
static ProjectController get instance =>
Magic.findOrPut(ProjectController.new);
}
Magic.findOrPut(ProjectController.new) returns the registered ProjectController if there is one; otherwise it calls the constructor tear-off, registers the result and returns it. Every view that calls ProjectController.instance gets the same object.
Implement Async Methods#
Each async action follows one lifecycle:
- Guard against re-entrant calls with a flag.
- Call
setLoading(). - Make the HTTP call with
Http.get(),Http.post()and the like. - Call
setSuccess(value)(orsetEmpty()for an empty result) on the happy path,setError(message)on failure. - Reset the guard in
finally.
class ProjectController extends MagicController
with MagicStateMixin<List<Map<String, dynamic>>> {
static ProjectController get instance =>
Magic.findOrPut(ProjectController.new);
bool _isLoading = false;
/// Fetches all projects and exposes them through the 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'];
final projects = data is List
? data.cast<Map<String, dynamic>>()
: <Map<String, dynamic>>[];
if (projects.isEmpty) {
setEmpty();
return;
}
setSuccess(projects);
} catch (e, stackTrace) {
Log.error('[ProjectController.loadProjects] $e\n$stackTrace');
setError('An unexpected error occurred.');
} finally {
_isLoading = false;
}
}
}
| Method | Resulting state | Read with |
|---|---|---|
setLoading() |
loading | isLoading |
setSuccess(value) |
success, value available |
isSuccess, rxState |
setError(message) |
error, message stored | isError |
setEmpty() |
empty | isEmpty |
clearErrors() and hasErrors belong to the separate ValidatesRequests mixin, which a controller adds when it submits forms the backend validates.
A controller that caches the rows of one signed-in user or team also implements magic's SessionScoped, so a sign-out or a team switch cannot leave the previous session's rows on screen; see Session Scope.
Registration Decision Tree#
Does the controller need to be ready before any view renders?
│
├─ YES → Register eagerly in AppServiceProvider.boot()
│ Magic.findOrPut(ProjectController.new);
│ Views resolve the same singleton
│
└─ NO → Use lazy findOrPut() (default pattern)
static ProjectController get instance =>
Magic.findOrPut(ProjectController.new);
The first access registers it
Is the controller shared across multiple views?
│
├─ YES → findOrPut(): the same singleton everywhere
│
└─ NO → Consider a plain StatefulWidget with a local ChangeNotifier
(no container involvement for purely local state)
| Scenario | Registration | Example |
|---|---|---|
| Data needed right after sign-in (a badge count in the navigation) | Eager, AppServiceProvider.boot() |
A controller that polls from boot |
| Feature data (a project list, a report) | Lazy, findOrPut() |
ProjectController, loaded when its screen mounts |
| Single-view transient state (a modal open or closed) | Local ChangeNotifier |
No container |
Connecting State to Views#
Pattern A: MagicStatefulView#
Use MagicStatefulView<ControllerType> for full-page views. Its state class gets controller from Magic.find<ControllerType>(), which throws when the controller was never registered, and it listens to the controller for you. So register the controller before the view mounts: call instance in the route builder.
MagicRoute.page('/projects', () {
ProjectController.instance; // registers the controller before the view mounts
return const ProjectListView();
});
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) {
return WDiv(
className: 'flex flex-col',
children: [
MSPageHeader(
title: 'Projects',
subtitle: 'Manage your projects',
actions: [
WButton(
onTap: controller.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'),
],
);
}
}
renderState() picks the builder from the current state:
| Argument | Rendered when |
|---|---|
First positional (T value) => Widget |
The controller called setSuccess(value) |
onEmpty: |
The controller called setEmpty(), or the state is the initial one |
onError: (String message) => Widget |
The controller called setError(message) |
onLoading: |
The controller called setLoading(); without it renderState() draws a built-in loading indicator |
Pattern B: StatefulWidget + Manual Access#
Use a plain StatefulWidget and the instance accessor for a non-page widget (a dropdown, a card action, a sub-widget of an existing view):
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.rxState?.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#
Magic's test helpers reset the container between tests and fake the network, so a controller test needs no hand-written driver. MagicTest.init() registers the setUp and tearDown hooks; Http.fake() stubs responses by URL pattern (a leading / is ignored) and records requests for assertions:
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
import 'package:magic/testing.dart';
void main() {
MagicTest.init();
group('ProjectController', () {
late ProjectController controller;
setUp(() {
controller = ProjectController();
});
tearDown(() {
controller.dispose();
});
test('loadProjects success moves to the success state', () async {
final fake = Http.fake({
'api/projects': Http.response({
'data': [
{'id': 1, 'name': 'Apollo'},
{'id': 2, 'name': 'Borealis'},
],
}),
});
await controller.loadProjects();
expect(controller.isSuccess, isTrue);
fake.assertSent(
(request) => request.method == 'GET' && request.url == '/api/projects',
);
});
test('loadProjects 500 moves to the error state', () async {
Http.fake({
'api/projects': Http.response({}, 500),
});
await controller.loadProjects();
expect(controller.isError, isTrue);
});
test('loadProjects is not re-entrant', () async {
final fake = Http.fake({
'api/projects': Http.response({'data': []}),
});
await Future.wait([controller.loadProjects(), controller.loadProjects()]);
fake.assertSentCount(1);
});
});
}
In a test, construct the controller directly (ProjectController()) rather than through instance: it keeps the test independent of what the container cached. A test that depends on a starter feature flag sets it with Config.set('magic_starter.features.teams', true) in setUp. The faking API, stub patterns and assertions are in the Magic testing guide.
Complete Example#
An end-to-end ProjectList feature is three files and one route:
| File | Holds |
|---|---|
lib/app/controllers/project_controller.dart |
The ProjectController from Implement Async Methods |
lib/resources/views/projects/project_list_view.dart |
The ProjectListView from Pattern A |
test/app/controllers/project_controller_test.dart |
The test from Testing |
The route goes inside the starter's authenticated group, so the page renders in the app shell and shares its layout:
MagicRoute.group(
layout: (child) => MagicStarter.view.makeLayout('layout.app', child: child),
middleware: ['auth'],
layoutId: MagicStarterLayoutIds.app,
routes: () {
MagicRoute.page('/projects', () {
ProjectController.instance;
return const ProjectListView();
});
},
);
Related Documentation#
- Controllers: how the starter's own controllers apply the pattern.
- Service Provider: the boot sequence, where eager registration belongs.
- Session Scope: resetting a controller when the session changes.
- Views and Layouts:
MagicStatefulViewandrenderState()in the starter's views. - Magic IoC container:
findOrPutand the other bindings.