EthicsComputerCourses
Join training +91 90532 10052

HTTP & JSON

Flutter & Dart · Lesson 22 of 29 Intermediate 6 min read Updated

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.

Dart
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);
}
Output
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.

Dart
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);
  }
}
Output
[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:

Dart
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}');
  }
}
Output
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 http gives the package a prefix, so you write http.get and http.post.
  • Uri.https(host, path, queryParameters) builds a correctly encoded URL. Avoid gluing URLs together with string interpolation.
  • response.statusCode 200 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.

Dart
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 in initState or 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 assigned Future, 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

  • jsonDecode and jsonEncode from dart:convert convert between JSON text and Dart maps and lists.
  • Convert JSON to model classes with fromJson factories and toJson methods; patterns make parsing safe.
  • The http package sends requests: build URLs with Uri.https, check statusCode, add .timeout().
  • FutureBuilder shows loading, error and data states; create the Future in initState.
  • 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
Dart
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}');
  }
}
Output
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?

2. Where should a screen start its network request when using FutureBuilder?

3. A release build on Android cannot reach the API, but debug builds can. What is the likely cause?

4. Why should you not put a secret API key in your Flutter 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