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

ActionHTTP MethodExample Address
Read allGET/posts
Read oneGET/posts/1
CreatePOST/posts
UpdatePUT or PATCH/posts/1
DeleteDELETE/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.