Flutter Dio and REST CRUD
Most apps talk to a server. A REST API exposes data through web addresses and four basic actions: read, create, update, and delete. The http package handles simple calls. Dio is a popular alternative that adds timeouts, interceptors, automatic JSON handling, and clearer errors. Teams choose it when an app grows beyond a few requests.
How REST Maps to CRUD
| Action | HTTP Method | Example Address |
|---|---|---|
| Read all | GET | /posts |
| Read one | GET | /posts/1 |
| Create | POST | /posts |
| Update | PUT or PATCH | /posts/1 |
| Delete | DELETE | /posts/1 |
App ──── GET /posts ────────→ Server
←─── 200 + JSON list ────
App ──── POST /posts + JSON ─→ Server
←─── 201 Created ────────
Think of a restaurant. The method is the type of request (order, change, cancel), the address names the dish, and the status code is the waiter's reply.
Installing Dio
flutter pub add dio
Creating One Shared Client
Configure Dio once and reuse it everywhere. A shared client keeps the base address, timeouts, and headers in a single place.
import 'package:dio/dio.dart';
final Dio dio = Dio(
BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: Duration(seconds: 10),
receiveTimeout: Duration(seconds: 10),
headers: {'Accept': 'application/json'},
),
);
The address above belongs to a free practice API. It accepts requests and returns sample data without storing real changes.
A Model Class
class Post {
final int? id;
final String title;
final String body;
Post({this.id, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as int?,
title: json['title'] as String,
body: json['body'] as String,
);
}
Map<String, dynamic> toJson() {
return {'title': title, 'body': body};
}
}
Read: GET
Dio decodes JSON automatically, so response.data already holds a list or a map.
Future<List<Post>> fetchPosts() async {
final response = await dio.get('/posts');
final List items = response.data;
return items.map((json) => Post.fromJson(json)).toList();
}
Pass filters through queryParameters:
final response = await dio.get('/posts', queryParameters: {'userId': 1});
Create: POST
Future<Post> createPost(Post post) async {
final response = await dio.post('/posts', data: post.toJson());
return Post.fromJson(response.data);
}
Update: PUT
Future<Post> updatePost(int id, Post post) async {
final response = await dio.put('/posts/$id', data: post.toJson());
return Post.fromJson(response.data);
}
Use PUT to replace a full record and PATCH to change selected fields.
Delete: DELETE
Future<void> deletePost(int id) async {
await dio.delete('/posts/$id');
}
Interceptors
An interceptor is a checkpoint that every request and response passes through. Add one to attach a login token, print logs, or retry failed calls.
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onError: (error, handler) {
print('Request failed: ${error.message}');
handler.next(error);
},
),
);
Request ─→ [Interceptor: add token] ─→ Server
Response ←─ [Interceptor: log result] ←─
Showing Results in the Screen
Combine the call with a FutureBuilder. Store the future in initState, so a rebuild never restarts the request.
late Future<List<Post>> _posts;
@override
void initState() {
super.initState();
_posts = fetchPosts();
}
FutureBuilder<List<Post>>(
future: _posts,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Could not load posts'));
}
final posts = snapshot.data!;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, i) => ListTile(title: Text(posts[i].title)),
);
},
)
Cancelling Requests
A user may leave a screen before a download finishes. A CancelToken stops the request and saves data and battery.
final token = CancelToken();
dio.get('/posts', cancelToken: token);
token.cancel('Screen closed');
Protecting Users and Data
- Use HTTPS addresses for every request.
- Keep secret keys and passwords out of the app code, because anyone can unpack an app and read it.
- Store login tokens in secure storage instead of plain preferences.
- Request only the data the screen needs.
Practice Ideas
Build a screen that lists posts from the practice API, with a button to add a new post and a swipe gesture to delete one. Watch the status codes in the DevTools network view. Add an interceptor that prints the time each request takes.
Reading Status Codes
Every response carries a three-digit status code. Codes starting with 2 mean success: 200 for a normal reply and 201 for a newly created record. Codes starting with 4 signal a problem with the request: 400 for bad data, 401 for missing login, 404 for a missing record. Codes starting with 5 signal a server failure. Dio throws an exception for 4xx and 5xx codes by default, so a plain try block catches them. Reading the code lets the app choose the right message, such as asking the user to sign in again after a 401.
Pagination
Servers rarely send ten thousand records in one reply. They return pages of twenty or fifty items. Send a page number or a cursor with each request, and load the next page when the user scrolls near the bottom. This habit keeps screens fast and reduces mobile data use.
