Data Binding
This guide explains how to connect dashboard components to live data from API endpoints, static sources, and entity queries. You will learn about field mapping, nested JSON traversal, the DashboardDataStore for polling, and best practices for reactive data display.
Data Flow Overview
Connecting to API Endpoints
The ApiChartDataSource is the primary way to fetch data from HTTP endpoints:
final dataSource = ApiChartDataSource();
final data = await dataSource.fetchData(
mapping: ChartFieldMapping(
labelField: 'courseName',
valueField: 'enrollmentCount',
),
filters: {
'endpoint': 'https://api.lms.example.com/enrollments',
'dataArrayPath': 'data.enrollments',
},
);Field Mapping
The ChartFieldMapping tells the data source which fields in the API response map to which chart dimensions:
| Mapping Field | Maps To | Example |
|---|---|---|
labelField | DataPoint.label | 'courseName' -- displayed on x-axis or as pie slice label |
valueField | DataPoint.value | 'enrollmentCount' -- the numeric value |
seriesField | DataPoint.series | 'level' -- groups data into multiple series |
Smart Defaults
If the field mapping does not match, the data source falls back to common field names:
- Label: tries the specified field, then
key,name,label, thenItem N - Value: tries the specified field, then
value,count, then0
Nested JSON Traversal
Real APIs often return nested JSON. The data source supports three traversal patterns:
Dot Notation
Access nested objects with dot-separated paths:
{
"response": {
"data": {
"enrollments": [
{"name": "Dart Basics", "count": 120}
]
}
}
}filters: {
'endpoint': url,
'dataArrayPath': 'response.data.enrollments',
}Array Flattening
Flatten nested arrays with the [] operator:
{
"courses": [
{
"level": "beginner",
"modules": [
{"name": "Introduction", "students": 45},
{"name": "Basics", "students": 38}
]
}
]
}// Flatten courses[].modules into a flat list
// Each item merges parent fields into child
filters: {
'endpoint': url,
'dataArrayPath': 'courses[].modules',
}
mapping: ChartFieldMapping(
labelField: 'name', // From child (module)
valueField: 'students', // From child (module)
seriesField: 'level', // From parent (course)
),After flattening, each item contains both parent and child fields merged together.
Nested Field Names
Field names in the mapping also support dot notation:
mapping: ChartFieldMapping(
labelField: 'course.name', // Nested in each item
valueField: 'stats.enrollment', // Nested numeric field
),Auto-Detection
When no dataArrayPath is specified, the data source tries:
- If the root is an array, use it directly
- Look for common keys:
data,items,results - Use the first array value found in the root object
Static Data Sources
For dashboards that do not need API calls, provide static data directly. A store always needs a ComponentDataFormat, so define the response shape your component expects:
class SummaryDataFormat implements ComponentDataFormat {
@override
String get displayName => 'Summary';
@override
String get description => 'Dashboard summary counts';
@override
Map<String, dynamic> getExpectedFormat() => {
'data': {'type': 'object', 'required': true},
};
@override
ValidationResult validate(dynamic data) {
return DataFormatValidator.validate(data, this);
}
}
final summaryDataFormat = SummaryDataFormat();
final store = DashboardDataStore(
storeKey: 'course-distribution',
apiDataSource: ApiDataSource(baseUrl: ''),
dataFormat: summaryDataFormat,
);
await store.fetchStatic({
'data': {'total': 1247, 'active': 42},
});DashboardDataStore for Polling
The DashboardDataStore is a MobX store that wraps data fetching with observable state, validation, and polling support.
Creating a Store
final store = DashboardDataStore(
storeKey: 'enrollment-stats', // Unique identifier
apiDataSource: ApiDataSource(
baseUrl: 'https://api.lms.example.com',
),
dataFormat: summaryDataFormat, // Expected response format
);Fetching Data
// From API
await store.fetchFromApi('/api/enrollments/summary');
// With custom method
await store.fetchFromApi('/api/enrollments', method: 'POST');
// Static data
await store.fetchStatic({'total': 1247, 'active': 42});Observable State
The store exposes five observable properties that your UI can react to:
Observer(
builder: (_) {
// Loading state
if (store.isLoading) {
return const CircularProgressIndicator();
}
// Error state
if (store.error != null) {
return Text('Error: ${store.error}');
}
// Data available
final data = store.data;
final lastFetch = store.lastFetch;
return Column(
children: [
Text('Last updated: $lastFetch'),
buildDashboardContent(data),
],
);
},
)Setting Up Polling
// Start polling every 30 seconds
store.startPolling(
sourceType: 'api',
sourceData: {
'endpoint': '/api/enrollments/summary',
'method': 'GET',
},
intervalSeconds: 30,
);
// Stop polling
store.stopPolling();
// Manual refresh (one-time)
await store.refresh(
sourceType: 'api',
sourceData: {'endpoint': '/api/enrollments/summary'},
);Polling Behavior
- Polling waits the full interval before the first background fetch
- If a fetch fails, polling continues at the next interval
- Calling
startPollingwhen already polling is a no-op stopPollinggracefully terminates the polling loopclear()stops polling and resets all state
Data Binding in Components
Here is how to integrate data binding into a ComponentDescriptor render callback:
Pattern: Inline Fetch
For simple components, fetch data directly in the render widget:
final enrollmentStat = ComponentDescriptor(
schemaType: 'lms.enrollment_stat',
componentKey: 'lms_enrollment_stat',
displayName: 'Enrollment Stat',
properties: () => (PropertyCollectionBuilder()
..string('endpoint', 'API Endpoint',
defaultValue: '/api/enrollments/count',
)
..string('title', 'Title', defaultValue: 'Total Enrolled')
).buildCollection(),
render: (context, props) {
final endpoint = props.stringValue('endpoint') ?? '';
final title = props.stringValue('title') ?? '';
return FutureBuilder(
future: ApiDataSource(baseUrl: 'https://api.lms.example.com')
.fetch(endpoint: endpoint),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error loading data'));
}
final value = snapshot.data?['count'] ?? 0;
return StatCardView(title: title, value: value);
},
);
},
);Pattern: Store-Based with Polling
For components that need auto-refresh, use DashboardDataStore:
class TrendDataFormat implements ComponentDataFormat {
@override
String get displayName => 'Trend';
@override
String get description => 'Trend response containing a data object';
@override
Map<String, dynamic> getExpectedFormat() => {
'data': {'type': 'object', 'required': true},
};
@override
ValidationResult validate(dynamic data) {
return DataFormatValidator.validate(data, this);
}
}
final trendDataFormat = TrendDataFormat();
class PollingChartView extends StatefulWidget {
final PropertyCollection properties;
const PollingChartView({super.key, required this.properties});
@override
State<PollingChartView> createState() => _PollingChartViewState();
}
class _PollingChartViewState extends State<PollingChartView> {
late final DashboardDataStore _store;
@override
void initState() {
super.initState();
_store = DashboardDataStore(
storeKey: 'chart-${widget.properties.stringValue('endpoint')}',
apiDataSource: ApiDataSource(baseUrl: 'https://api.lms.example.com'),
dataFormat: trendDataFormat,
);
final endpoint = widget.properties.stringValue('endpoint') ?? '';
_store.fetchFromApi(endpoint);
final enablePolling = widget.properties.boolValue('enablePolling') ?? false;
if (enablePolling) {
final interval = widget.properties.intValue('pollingInterval') ?? 30;
_store.startPolling(
sourceType: 'api',
sourceData: {'endpoint': endpoint},
intervalSeconds: interval,
);
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
if (_store.isLoading && _store.data == null) {
return const Center(child: CircularProgressIndicator());
}
if (_store.error != null && _store.data == null) {
return Center(child: Text(_store.error!));
}
return buildChart(context, _store.data);
},
);
}
@override
void dispose() {
_store.clear();
super.dispose();
}
}LMS Example: Connecting to Enrollment API
API Response
{
"data": {
"summary": {
"totalStudents": 1247,
"activeCourses": 42,
"completionRate": 87.5,
"averageScore": 78.2
},
"enrollmentsByMonth": [
{"month": "Jan", "count": 120, "level": "beginner"},
{"month": "Jan", "count": 85, "level": "intermediate"},
{"month": "Jan", "count": 45, "level": "advanced"},
{"month": "Feb", "count": 140, "level": "beginner"}
]
}
}Connecting Stat Cards
// Fetch summary and distribute to stat cards
final store = DashboardDataStore(
storeKey: 'lms-summary',
apiDataSource: ApiDataSource(baseUrl: 'https://api.lms.example.com'),
dataFormat: summaryDataFormat,
);
await store.fetchFromApi('/api/dashboard/summary');
// In render callback, access nested fields
final summary = (store.data as Map)['data']['summary'];
final totalStudents = summary['totalStudents'] as int;Connecting the Enrollment Chart
final data = await ApiChartDataSource().fetchData(
mapping: ChartFieldMapping(
labelField: 'month',
valueField: 'count',
seriesField: 'level',
),
filters: {
'endpoint': 'https://api.lms.example.com/api/dashboard/summary',
'dataArrayPath': 'data.enrollmentsByMonth',
},
);
// data now contains DataPoint instances ready for chart rendering
final chartData = ChartData(type: ChartType.multiLine, data: data);Next Steps
- Extensions -- Build custom extensions for auto-refresh and more
- Chart System -- Understand chart renderers
- LMS Dashboard Example -- Complete working example