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
Note: Once you have a List<Article>, every widget that touches it gets full autocomplete on .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),
              );
            },
          );
        }
      },
    );
  }
}
Next up: Passing this article list around by re-fetching or lifting state manually gets unwieldy fast — see how a shared, app-wide state solution helps in State Management: Provider.