Conectar tu app Flutter a datos externos significa hacer peticiones de red usando el paquete http para obtener información de una API REST y mostrarla en pantalla.
Casi toda app real consume datos de un servidor. Un catálogo de productos de Liverpool, los precios del día de FEMSA, o el historial de pedidos de Mercado Libre: todos llegan a través de peticiones HTTP. En esta lección aprenderás el flujo completo: agregar el paquete, hacer una petición GET, parsear el JSON y mostrar los resultados con FutureBuilder.
El paquete http y cómo agregarlo
Dart no incluye un cliente HTTP listo para producción en su librería estándar para Flutter. El paquete oficial es http, publicado por el equipo de Dart en pub.dev.
Abre tu archivo pubspec.yaml y agrega la dependencia:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
Guarda el archivo y ejecuta flutter pub get en tu terminal. Eso descarga el paquete y lo deja listo para importar.
En cualquier archivo .dart donde lo necesites, escribe:
import 'package:http/http.dart' as http;
import 'dart:convert';
dart:convert es la librería estándar que transforma JSON en objetos Dart. Ambos imports son necesarios.
Estructura de una petición GET
Una petición GET tiene tres pasos: construir la URL, llamar http.get(), y procesar la respuesta.
Future<List<dynamic>> obtenerProductos() async {
final url = Uri.parse('https://api.ejemplo.mx/productos');
final respuesta = await http.get(url);
if (respuesta.statusCode == 200) {
final datos = jsonDecode(respuesta.body);
return datos as List<dynamic>;
} else {
throw Exception('Error al cargar productos: ${respuesta.statusCode}');
}
}
Uri.parse() convierte el texto de la URL en un objeto que Flutter entiende. await pausa la función hasta que el servidor responde. El campo statusCode indica si la petición fue exitosa: 200 significa OK.
Si el servidor regresa un código diferente a 200, lanzamos una excepción. Esto permite manejar el error en la UI de forma limpia.
Modelar los datos con una clase
No trabajes con Map<String, dynamic> sueltos en toda tu app. Crea una clase modelo para tipar los datos.
Supón que tu API regresa productos de un catálogo similar al de Liverpool:
class Producto {
final int id;
final String nombre;
final double precio;
Producto({required this.id, required this.nombre, required this.precio});
factory Producto.fromJson(Map<String, dynamic> json) {
return Producto(
id: json['id'],
nombre: json['nombre'],
precio: (json['precio'] as num).toDouble(),
);
}
String get precioFormateado {
final valor = precio.toInt();
final partes = valor.toString().replaceAllMapped(
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
(m) => '${m[1]},',
);
return '\$$partes';
}
}
El método factory Producto.fromJson construye un objeto desde un Map. El getter precioFormateado devuelve el precio con formato como $1,200 o $18,500. Ahora actualiza la función para devolver objetos tipados:
Future<List<Producto>> obtenerProductos() async {
final url = Uri.parse('https://api.ejemplo.mx/productos');
final respuesta = await http.get(url);
if (respuesta.statusCode == 200) {
final List<dynamic> datos = jsonDecode(respuesta.body);
return datos.map((item) => Producto.fromJson(item)).toList();
} else {
throw Exception('Error al cargar productos');
}
}
FutureBuilder: los tres estados de la UI
FutureBuilder es el widget que conecta un Future con la interfaz. Maneja automáticamente tres estados: carga, error y éxito.
class CatalogoScreen extends StatelessWidget {
const CatalogoScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Catálogo de productos')),
body: FutureBuilder<List<Producto>>(
future: obtenerProductos(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text('Ocurrió un error: ${snapshot.error}'),
);
}
final productos = snapshot.data!;
return ListView.builder(
itemCount: productos.length,
itemBuilder: (context, index) {
final p = productos[index];
return ListTile(
title: Text(p.nombre),
trailing: Text(p.precioFormateado),
);
},
);
},
),
);
}
}
El parámetro future recibe el Future que quieres observar. El parámetro builder recibe un snapshot con toda la información del estado actual.
| Estado | Condición en el snapshot | Widget recomendado |
|---|---|---|
| Cargando | connectionState == ConnectionState.waiting |
CircularProgressIndicator |
| Error | snapshot.hasError == true |
Text con mensaje de error |
| Éxito | snapshot.hasData == true |
ListView, Column, etc. |
Evitar llamadas duplicadas con una variable de instancia
Hay un problema común con FutureBuilder: si el widget se reconstruye (por ejemplo, al rotar la pantalla), llama obtenerProductos() de nuevo. Eso genera peticiones de red innecesarias.
La solución es guardar el Future en una variable de estado:
class CatalogoScreen extends StatefulWidget {
const CatalogoScreen({super.key});
@override
State<CatalogoScreen> createState() => _CatalogoScreenState();
}
class _CatalogoScreenState extends State<CatalogoScreen> {
late Future<List<Producto>> _futureProductos;
@override
void initState() {
super.initState();
_futureProductos = obtenerProductos();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Catálogo')),
body: FutureBuilder<List<Producto>>(
future: _futureProductos,
builder: (context, snapshot) {
// misma lógica de antes
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final productos = snapshot.data!;
return ListView.builder(
itemCount: productos.length,
itemBuilder: (context, i) => ListTile(
title: Text(productos[i].nombre),
trailing: Text(productos[i].precioFormateado),
),
);
},
),
);
}
}
Ahora obtenerProductos() se llama una sola vez en initState. El FutureBuilder observa _futureProductos sin recrearlo en cada rebuild.
Agregar encabezados a la petición
Algunas APIs requieren un token de autenticación o un encabezado Content-Type. Pásalos en el parámetro headers de http.get():
final respuesta = await http.get(
url,
headers: {
'Authorization': 'Bearer mi_token_secreto',
'Accept': 'application/json',
},
);
Esto es útil cuando consumes APIs privadas de empresas como FEMSA o Bimbo que requieren autenticación por token.
Errores comunes
1. Olvidar el permiso de internet en Android.
Flutter en Android necesita una declaración explícita en AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Sin esto, todas tus peticiones fallarán en silencio o con un error de socket. Agrégala justo antes de la etiqueta <application>.
2. Llamar http.get() directamente dentro de build().
Cada vez que Flutter reconstruye el widget, se dispara una nueva petición de red. Esto puede generar decenas de llamadas por segundo. Siempre guarda el Future en initState o en una variable de clase.
3. No manejar el estado de error.
Muchos principiantes solo manejan el estado de carga y el de éxito. Si la red falla o el servidor regresa un 500, la app se congela o muestra una pantalla en blanco. Siempre verifica snapshot.hasError.
4. Usar snapshot.data sin el operador ! o sin verificar hasData.
Después de confirmar que no hay error y que el estado es de éxito, snapshot.data puede ser nulo si el servidor regresó un cuerpo vacío. Verifica snapshot.hasData antes de acceder a los datos, o usa snapshot.data! solo cuando estés seguro.
Resumen rápido del flujo
- Agrega
httpenpubspec.yamly ejecutaflutter pub get. - Crea una función
asyncque construya la URL, llamehttp.get(), y parsee el JSON. - Modela los datos con una clase que tenga un constructor
factory fromJson. - Guarda el
FutureeninitStatepara evitar peticiones repetidas. - Usa
FutureBuilderpara manejar los tres estados: carga, error y éxito.