Week 9: REST Networking with http/dio & JSON

Every screen so far has used fake, hardcoded data. This week connects the app to a real backend — the same skill covered from the server side in this site's Node.js and Go courses — using both Dart's lightweight http package and the more full-featured dio, plus the loading/error state pattern every real API call needs.

Phase 5 of 8 Week 9 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Make a GET and POST request with the http package
  • Serialize and deserialize JSON into typed Dart classes
  • Use dio for interceptors, timeouts, and richer error handling
  • Model loading, error, and success states for an API call with a Riverpod provider

1. REST Calls with http

terminal
flutter pub add http
a GET request
import 'package:http/http.dart' as http;
import 'dart:convert';

Future<List<dynamic>> fetchProducts() async {
  final response = await http.get(Uri.parse('https://api.example.com/products'));
  if (response.statusCode != 200) {
    throw Exception('Failed to load products: ${response.statusCode}');
  }
  return jsonDecode(response.body) as List<dynamic>;
}
a POST request with a JSON body
Future<void> createProduct(String name, double price) async {
  final response = await http.post(
    Uri.parse('https://api.example.com/products'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'name': name, 'price': price}),
  );
  if (response.statusCode != 201) {
    throw Exception('Failed to create product');
  }
}

2. Typed JSON Models

Working with raw Map<String, dynamic> everywhere loses type safety and pushes key-name typos to runtime. A typed model with fromJson/toJson fixes that.

models/product.dart
class Product {
  final int id;
  final String name;
  final double price;

  Product({required this.id, required this.name, required this.price});

  factory Product.fromJson(Map<String, dynamic> json) {
    return Product(
      id: json['id'] as int,
      name: json['name'] as String,
      price: (json['price'] as num).toDouble(),
    );
  }

  Map<String, dynamic> toJson() => {'id': id, 'name': name, 'price': price};
}
using it
Future<List<Product>> fetchProducts() async {
  final response = await http.get(Uri.parse('https://api.example.com/products'));
  final list = jsonDecode(response.body) as List<dynamic>;
  return list.map((json) => Product.fromJson(json as Map<String, dynamic>)).toList();
}

For a larger app, the json_serializable package generates this boilerplate from annotations — worth adopting once hand-writing fromJson/toJson for a few dozen models gets tedious.

3. dio: Interceptors, Timeouts & Errors

terminal
flutter pub add dio
a configured dio client with an auth interceptor
final dio = Dio(BaseOptions(
  baseUrl: 'https://api.example.com',
  connectTimeout: const Duration(seconds: 5),
  receiveTimeout: const Duration(seconds: 5),
));

dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) {
    options.headers['Authorization'] = 'Bearer $currentToken';
    handler.next(options);
  },
  onError: (error, handler) {
    if (error.response?.statusCode == 401) {
      // token expired — trigger a re-auth flow here
    }
    handler.next(error);
  },
));

final response = await dio.get('/products');

http is enough for simple call sites; dio earns its extra dependency once an app needs shared configuration (a base URL, default headers), an interceptor pattern (auth tokens, logging), or built-in timeout handling across every request rather than reimplementing it per call.

4. Loading, Error & Success State

a Riverpod FutureProvider — handles loading/error automatically
final productsProvider = FutureProvider<List<Product>>((ref) => fetchProducts());
consuming it with AsyncValue's built-in states
class ProductListScreen extends ConsumerWidget {
  const ProductListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsProvider);

    return productsAsync.when(
      data: (products) => ListView(
        children: products.map((p) => ListTile(title: Text(p.name))).toList(),
      ),
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (err, stack) => Center(child: Text('Error: $err')),
    );
  }
}

FutureProvider wraps the async call in an AsyncValue, whose .when forces handling all three states explicitly — there's no way to accidentally forget the error case, which a hand-rolled isLoading/hasError boolean pair makes easy to miss.

5. Hands-on Exercise

Hands-on

Connect the product list to a real API

Replace hardcoded product data with a real (or public test) API, with correct loading/error handling.

Requirements:

  1. A Product model with fromJson/toJson, and a repository function fetching a list from a real endpoint (a public test API like fakestoreapi.com works well).
  2. A FutureProvider wrapping the fetch, consumed with AsyncValue.when covering data, loading, and error explicitly.
  3. A create-product form using either http or dio to POST a new product, with a loading indicator on the submit button while the request is in flight.
  4. Deliberately point the fetch at an invalid URL temporarily and confirm the error state renders a real, useful message rather than crashing or showing a blank screen.
  5. A dio version of the same GET request with a 5-second timeout configured, tested by simulating a slow/unreachable endpoint.
Hint

If jsonDecode throws a type error on a field you're confident is present, check the JSON's actual type against your cast — a numeric field like price often arrives as an int when it happens to have no decimal places, which breaks a direct as double cast; casting through (json['price'] as num).toDouble(), as shown in this week's model, handles both cases correctly.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does casting a JSON field with (json['price'] as num).toDouble() avoid a bug that json['price'] as double can hit?

JSON numbers don't distinguish int from double the way Dart does — a server sending price: 10 (no decimal) decodes to a Dart int, and casting that directly as double throws. Casting through the shared num supertype first, then calling .toDouble(), correctly handles a JSON number arriving as either an int or a double.

Q2

What specific capability does dio provide that would require manually reimplementing per call site with plain http?

Shared, centralized configuration and behavior across every request — a base URL, default timeouts, and interceptors that run on every request/response (like automatically attaching an auth header, or reacting to every 401). With plain http, each of those would need to be manually repeated at every individual call site instead of configured once.

Q3

Why does using AsyncValue.when make it harder to accidentally ship a screen with no error handling, compared to a hand-rolled isLoading/hasError boolean pair?

.when requires a callback for all three branches — data, loading, and error — as a matter of the method's own required parameters, so omitting the error case is a compile error, not just an easy oversight. A hand-rolled boolean-flag approach has no such enforcement; it's entirely possible to check isLoading and forget to ever check (or render) the error case.

Q4

Why does the exercise ask you to deliberately point the fetch at an invalid URL, rather than just trusting the error branch works because it compiles?

Code compiling only confirms the error branch is syntactically present and type-correct — it says nothing about whether the actual error-handling logic behaves correctly, renders something useful, or avoids crashing when a real failure occurs. Deliberately triggering the failure path is the only way to actually verify the error state does what it's supposed to, the same reasoning behind testing any error path rather than assuming it from the code alone.