HTTP & JSON
Most apps talk to a server: a weather app fetches the forecast, a shop loads products, a school app downloads timetables. They usually do this over HTTP (the same protocol your browser uses) and exchange data as JSON, a simple text format for objects and lists.
In this lesson you will decode JSON into Dart classes, fetch data with the official http package, deal with slow or failing networks, and show the result on screen with loading and error states. The pure Dart parts run anywhere with dart run; the last part puts them into a Flutter screen.
JSON and dart:convert
JSON looks like Dart maps and lists: objects in { } with string keys, arrays in [ ], plus strings, numbers, true, false and null. The core library dart:convert turns JSON text into Dart values with jsonDecode and back with jsonEncode.
import 'dart:convert';
void main() {
const body = '{"name": "Meera", "city": "Kochi", "marks": [88, 92], "hostel": false}';
final data = jsonDecode(body) as Map<String, dynamic>;
print(data['name']);
print(data['marks']);
print((data['marks'] as List).length);
final reply = jsonEncode({'status': 'ok', 'roll': 17, 'subjects': ['Maths', 'Biology']});
print(reply);
}Meera
[88, 92]
2
{"status":"ok","roll":17,"subjects":["Maths","Biology"]}jsonDecode returns dynamic, because it cannot know the shape of your data. Working with Map<String, dynamic> everywhere is error-prone: a typo in a key gives null at run time, not a compile error. The fix is to convert JSON into a typed class as soon as it arrives.
Model classes with fromJson and toJson
A model class describes one kind of object from the API. By convention it has a fromJson factory constructor and a toJson method. Patterns (from Records & Patterns) make fromJson safe: they check every type and fail clearly if the data has the wrong shape.
import 'dart:convert';
class Product {
const Product({required this.id, required this.name, required this.pricePaise, this.inStock = true});
final int id;
final String name;
final int pricePaise;
final bool inStock;
factory Product.fromJson(Map<String, dynamic> json) {
return switch (json) {
{'id': int id, 'name': String name, 'price_paise': int price} => Product(
id: id,
name: name,
pricePaise: price,
inStock: json['in_stock'] as bool? ?? true,
),
_ => throw FormatException('Invalid product JSON: $json'),
};
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'price_paise': pricePaise,
'in_stock': inStock,
};
@override
String toString() => '$name (₹${pricePaise ~/ 100})';
}
void main() {
const body = '''
[
{"id": 1, "name": "Basmati rice 5 kg", "price_paise": 64900},
{"id": 2, "name": "Toor dal 1 kg", "price_paise": 16500, "in_stock": false}
]''';
final products = (jsonDecode(body) as List)
.map((item) => Product.fromJson(item as Map<String, dynamic>))
.toList();
print(products);
print(products[1].inStock);
print(jsonEncode(products.first.toJson()));
try {
Product.fromJson({'id': '3', 'name': 'Sugar'});
} on FormatException catch (e) {
print(e.message);
}
}[Basmati rice 5 kg (₹649), Toor dal 1 kg (₹165)]
false
{"id":1,"name":"Basmati rice 5 kg","price_paise":64900,"in_stock":true}
Invalid product JSON: {id: 3, name: Sugar}The last call fails because id is the string '3', not an int, and the error says exactly what was wrong. After decoding, the rest of your app works only with Product objects, so the compiler checks every field name and type. For large models, many teams generate fromJson and toJson with the json_serializable package instead of writing them by hand.
Fetching data with the http package
The http package from the Dart team makes HTTP requests on every platform. Add it with flutter pub add http (or dart pub add http in a Dart-only project). This complete program fetches data from JSONPlaceholder, a free fake API for practice:
import 'dart:convert';
import 'package:http/http.dart' as http;
class Todo {
Todo({required this.id, required this.title, required this.completed});
final int id;
final String title;
final bool completed;
factory Todo.fromJson(Map<String, dynamic> json) => Todo(
id: json['id'] as int,
title: json['title'] as String,
completed: json['completed'] as bool,
);
}
Future<List<Todo>> fetchTodos({int limit = 3}) async {
final url = Uri.https('jsonplaceholder.typicode.com', '/todos', {
'userId': '1',
'_limit': '$limit',
});
final response = await http.get(url).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Server error ${response.statusCode}');
}
final list = jsonDecode(response.body) as List;
return list.map((e) => Todo.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> main() async {
final todos = await fetchTodos();
for (final t in todos) {
print('${t.id}. ${t.completed ? '[x]' : '[ ]'} ${t.title}');
}
}1. [ ] delectus aut autem 2. [ ] quis ut nam facilis et officia qui 3. [ ] fugiat veniam minus
The titles are placeholder Latin text from the practice API. Notes:
import ... as httpgives the package a prefix, so you writehttp.getandhttp.post.Uri.https(host, path, queryParameters)builds a correctly encoded URL. Avoid gluing URLs together with string interpolation.response.statusCode200 means success. Always check it: a 404 or 500 response still has a body, but it is not your data..timeout()stops waiting after 10 seconds instead of spinning forever on a weak mobile connection.
To send data, use http.post(url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(order.toJson())). Never put secret API keys inside an app: anyone can extract them from the installed app. Keep secrets on your own server and let the app call that server.
Showing remote data in Flutter
A screen that loads data has three states: loading, error and success. FutureBuilder rebuilds when a Future completes and gives you a snapshot describing its state.
import 'package:flutter/material.dart';
class Todo {
const Todo(this.title, this.completed);
final String title;
final bool completed;
}
class TodoScreen extends StatefulWidget {
const TodoScreen({super.key, required this.loader});
final Future<List<Todo>> Function() loader;
@override
State<TodoScreen> createState() => _TodoScreenState();
}
class _TodoScreenState extends State<TodoScreen> {
late Future<List<Todo>> _future;
@override
void initState() {
super.initState();
_future = widget.loader();
}
void _retry() {
setState(() {
_future = widget.loader();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My tasks')),
body: FutureBuilder<List<Todo>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Could not load tasks. Check your internet.'),
TextButton(onPressed: _retry, child: const Text('Try again')),
],
),
);
}
final todos = snapshot.data!;
if (todos.isEmpty) return const Center(child: Text('No tasks'));
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, i) => CheckboxListTile(
value: todos[i].completed,
onChanged: null,
title: Text(todos[i].title),
),
);
},
),
);
}
}When the screen opens, a spinning circle shows in the centre. When the data arrives, it is replaced by a list of tasks with (read-only) checkboxes. If the request fails, the screen shows "Could not load tasks. Check your internet." with a Try again button that starts a new request and shows the spinner again.
The Future is created in initState, not in build. If you wrote future: widget.loader() inside build, every rebuild would start a new network request. Passing the loader function in, rather than calling http inside the widget, also makes the screen easy to test with fake data; in a bigger app this function would live in a repository class, as described in State Management.
Common mistakes
- Calling the API inside
build. Start requests ininitStateor a view model, not on every rebuild. - Not checking
statusCode. A 500 error page decoded as JSON gives confusing errors later. - Using
Map<String, dynamic>across the app. Convert to model classes at the edge. - No timeout and no retry. Mobile networks drop; give users a clear error and a way to try again.
setState(() => _future = load()). The arrow returns the assignedFuture, and Flutter throws "setState() callback argument returned a Future". Use a block body:setState(() { _future = load(); });.- Shipping API secrets in the app. Anything in the app can be read by users.
Key points
jsonDecodeandjsonEncodefromdart:convertconvert between JSON text and Dart maps and lists.- Convert JSON to model classes with
fromJsonfactories andtoJsonmethods; patterns make parsing safe. - The
httppackage sends requests: build URLs withUri.https, checkstatusCode, add.timeout(). FutureBuildershows loading, error and data states; create the Future ininitState.- Release Android builds need the INTERNET permission in the main manifest.
Exercise
Write a pure Dart program with a WeatherReport model (city, tempC as a double, condition) and a fromJson factory that uses a switch with a map pattern. Decode this JSON and print one line per city, such as Jaipur: 34.5°C, Sunny. Make the parser accept a whole-number temperature such as 29 as well (hint: match num tempC and call toDouble()).
[{"city": "Jaipur", "temp_c": 34.5, "condition": "Sunny"}, {"city": "Kochi", "temp_c": 29, "condition": "Rain"}]
Show solution
import 'dart:convert';
class WeatherReport {
const WeatherReport(this.city, this.tempC, this.condition);
final String city;
final double tempC;
final String condition;
factory WeatherReport.fromJson(Map<String, dynamic> json) => switch (json) {
{'city': String city, 'temp_c': num temp, 'condition': String condition} =>
WeatherReport(city, temp.toDouble(), condition),
_ => throw FormatException('Bad weather JSON: $json'),
};
}
void main() {
const body = '[{"city": "Jaipur", "temp_c": 34.5, "condition": "Sunny"}, '
'{"city": "Kochi", "temp_c": 29, "condition": "Rain"}]';
final reports = (jsonDecode(body) as List)
.map((e) => WeatherReport.fromJson(e as Map<String, dynamic>));
for (final r in reports) {
print('${r.city}: ${r.tempC}°C, ${r.condition}');
}
}Jaipur: 34.5°C, Sunny Kochi: 29.0°C, Rain
JSON numbers without a decimal point decode as int, so matching num and calling toDouble() accepts both forms. Kochi's 29 is printed as 29.0 because it is now a double.
Quick Quiz
1. What type does jsonDecode('{"a": 1}') return at compile time?
jsonDecode cannot know the shape of the JSON, so its static type is dynamic.
2. Where should a screen start its network request when using FutureBuilder?
Creating the Future in build would start a new request on every rebuild.
3. A release build on Android cannot reach the API, but debug builds can. What is the likely cause?
Flutter adds the permission only to the debug and profile manifests.
4. Why should you not put a secret API key in your Flutter app?
Keep secrets on a server you control and call that server from the app.
Learn Flutter & Dart with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
