JSON Serialization
The previous lesson got you a raw String of JSON text back from an API
call. That text is useless to your widgets as-is — you need to turn it into real Dart objects you can
read fields from, loop over, and pass around with type safety. This lesson covers parsing JSON with
dart:convert, why you should almost never work with raw maps directly in a growing app, and
the standard model-class pattern that fixes that.
Parsing JSON With jsonDecode
Dart's built-in dart:convert library provides jsonDecode(), which turns a raw
JSON string into ordinary Dart objects — a JSON object becomes a Map<String, dynamic>,
and a JSON array becomes a List<dynamic>.
import 'dart:convert';
void parseExample() {
const jsonString = '{"id": 1, "title": "Hello Flutter", "body": "A first article"}';
final Map<String, dynamic> article = jsonDecode(jsonString);
print(article['title']); // Hello Flutter
print(article['id']); // 1
}
Why Raw Maps Get Error-Prone
Working with Map<String, dynamic>
everywhere feels fine for a quick script, but it doesn't scale. Every access is a string key lookup —
article['tilte'], a typo, compiles perfectly fine and simply returns null
at runtime instead of failing loudly. There's no autocomplete for field names, no compile-time check
that a field is actually a String rather than an int,
and if the same article data is used in five different widgets, all five have to independently agree on
what keys exist and what types they hold.
The Standard Solution: A Model Class
The fix is to parse the raw map exactly once, at the boundary where data enters your app, into a proper
Dart class with typed fields. That class gets a named constructor — conventionally called
fromJson — that takes the raw map and builds a real instance, plus a toJson
method that does the reverse, useful when you need to send the object back to a server.
class Article {
final int id;
final String title;
final String body;
Article({required this.id, required this.title, required this.body});
// Named constructor: builds an Article from a raw JSON map
factory Article.fromJson(Map<String, dynamic> json) {
return Article(
id: json['id'],
title: json['title'],
body: json['body'],
);
}
// The reverse: turns this Article back into a JSON-ready map
Map<String, dynamic> toJson() {
return {'id': id, 'title': title, 'body': body};
}
}
Converting a whole JSON array of raw objects into a typed List<Article>
is a one-line .map() call away:
const jsonArray = '[{"id":1,"title":"First","body":"..."},{"id":2,"title":"Second","body":"..."}]';
final List<dynamic> rawList = jsonDecode(jsonArray);
final List<Article> articles =
rawList.map((json) => Article.fromJson(json)).toList();
print(articles.first.title); // First
.title, .body, and .id, and a typo like .tilte is now a compile-time error instead of a silent runtime bug.A Brief Word on json_serializable
Writing fromJson/toJson by hand is fine for a handful of small models, but it
becomes repetitive boilerplate once an app has dozens of them, especially ones with nested objects or
lists of nested objects. The json_serializable package (paired with
build_runner) auto-generates this exact boilerplate for you from simple annotations on
your class — you write the field list once, and a generated file supplies the parsing code. This is
conceptual awareness for now; the full build_runner code-generation setup is beyond this lesson, but
recognize the name for when a real project's models start to multiply.
Full Example: Fetching and Displaying Typed Articles
Combining everything from this lesson and the last: fetch raw JSON with http.get, convert
it into a List<Article> using the
model class above, and render it with a FutureBuilder feeding a
ListView.builder (see Lists & ListView).
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
Future<List<Article>> fetchArticles() async {
final response = await http.get(Uri.parse('https://api.example.com/articles'));
if (response.statusCode != 200) {
throw Exception('Failed to load articles');
}
final List<dynamic> rawList = jsonDecode(response.body);
return rawList.map((json) => Article.fromJson(json)).toList();
}
class ArticleListScreen extends StatefulWidget {
const ArticleListScreen({super.key});
@override
State<ArticleListScreen> createState() => _ArticleListScreenState();
}
class _ArticleListScreenState extends State<ArticleListScreen> {
late final Future<List<Article>> _articlesFuture;
@override
void initState() {
super.initState();
_articlesFuture = fetchArticles();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Article>>(
future: _articlesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else {
final articles = snapshot.data!;
return ListView.builder(
itemCount: articles.length,
itemBuilder: (context, index) {
final article = articles[index];
return ListTile(
key: ValueKey(article.id),
title: Text(article.title),
subtitle: Text(article.body),
);
},
);
}
},
);
}
}