19  HTTP: POST

Great! Let’s dive into HTTP POST requests and the FutureBuilder widget. I’ll break this down into digestible parts.

19.1 Understanding HTTP POST vs GET

The key difference between POST and GET requests:

GET:  Request data from server (read operation)
POST: Send data to server to create something new (write operation)

19.2 Step 1: The HTTP POST Request Function

Future<Album> createAlbum(String title) async {
  final response = await http.post(
    Uri.parse('https://jsonplaceholder.typicode.com/albums'),
    headers: <String, String>{
      'Content-Type': 'application/json; charset=UTF-8',
    },
    body: jsonEncode(<String, String>{'title': title}),
  );

  if (response.statusCode == 201) {
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    throw Exception('Failed to create album.');
  }
}

Let’s break down the POST request components:

URL: Notice we’re posting to /albums (without the /1). This is typically the collection endpoint where new resources are created.

Headers: The headers tell the server what kind of data we’re sending:

headers: <String, String>{
  'Content-Type': 'application/json; charset=UTF-8',
}

This is like setting headers in Python’s requests:

headers = {'Content-Type': 'application/json; charset=UTF-8'}

Request Body: We convert our data to JSON and send it in the body:

body: jsonEncode(<String, String>{'title': title})

This creates a JSON string like {"title": "My Album Title"}. In Python, this would be:

body = json.dumps({'title': title})

Status Code Check: POST requests typically return 201 Created instead of 200 OK, indicating a new resource was successfully created.

19.3 Step 2: Understanding the Application State

The app has two key pieces of state:

class _MyAppState extends State<MyApp> {
  final TextEditingController _controller = TextEditingController();
  Future<Album>? _futureAlbum;
  // ...
}

TextEditingController: Manages the text input field, similar to controlled components in React or form handling in web frameworks.

**_futureAlbum**: This is nullable (Future<Album>?) and represents the current state of our POST request:

  • null = No request has been made yet
  • Future<Album> = Request is in progress or completed

19.4 Step 3: The User Interface Flow

The app shows different content based on the state:

child: (_futureAlbum == null) ? buildColumn() : buildFutureBuilder(),

This creates a simple state machine:

Initial State: Show input form
After button press: Show request progress/result

19.5 Step 4: Understanding FutureBuilder

FutureBuilder is Flutter’s way of handling asynchronous operations in the UI. It’s similar to React’s useEffect with async operations:

FutureBuilder<Album> buildFutureBuilder() {
  return FutureBuilder<Album>(
    future: _futureAlbum,
    builder: (context, snapshot) {
      if (snapshot.hasData) {
        return Text(snapshot.data!.title);
      } else if (snapshot.hasError) {
        return Text('${snapshot.error}');
      }
      return const CircularProgressIndicator();
    },
  );
}

How FutureBuilder Works:

  1. Initial State: When the Future starts, snapshot.hasData and snapshot.hasError are both false, so it shows the loading spinner.

  2. Success State: When the HTTP request completes successfully, snapshot.hasData becomes true, and snapshot.data contains the Album object.

  3. Error State: If the request fails, snapshot.hasError becomes true, and snapshot.error contains the exception.

The AsyncSnapshot Object:

AsyncSnapshot<Album> snapshot
├── hasData: bool     // True when data is available
├── hasError: bool    // True when an error occurred
├── data: Album?      // The actual data (null if no data)
└── error: Object?    // The error object (null if no error)

19.6 Step 5: The Complete Request Flow

Here’s what happens when you press the “Create Data” button:

  1. Button Press:

    onPressed: () {
      setState(() {
        _futureAlbum = createAlbum(_controller.text);
      });
    }
  2. State Update: setState() triggers a rebuild, and now _futureAlbum is not null, so buildFutureBuilder() is called.

  3. HTTP Request: createAlbum() starts executing:

    • Creates JSON payload: {"title": "user input"}
    • Sends POST request with proper headers
    • Waits for server response
  4. UI Updates: FutureBuilder automatically rebuilds as the Future progresses:

    • Initially: Shows CircularProgressIndicator
    • On success: Shows the created album title
    • On error: Shows the error message

19.7 Comparing to Python/JavaScript

If you were to write this in Python with a web framework like FastAPI:

import asyncio
import aiohttp
import json

async def create_album(title: str):
    async with aiohttp.ClientSession() as session:
        async with session.post(
            'https://jsonplaceholder.typicode.com/albums',
            headers={'Content-Type': 'application/json'},
            json={'title': title}
        ) as response:
            if response.status == 201:
                data = await response.json()
                return Album(**data)
            else:
                raise Exception('Failed to create album')

Or in JavaScript with fetch:

async function createAlbum(title) {
  const response = await fetch('https://jsonplaceholder.typicode.com/albums', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({title: title}),
  });
  
  if (response.status === 201) {
    return await response.json();
  } else {
    throw new Error('Failed to create album');
  }
}

19.8 Key Concepts to Remember

State Management: Flutter uses setState() to trigger UI rebuilds when data changes. This is similar to React’s useState hook.

FutureBuilder Pattern: This widget automatically manages the three states of async operations (loading, success, error) without you having to manually track them.

Immutable State: Each time you call setState(), Flutter creates a new build of your widget tree, ensuring consistent state management.

Error Handling: The FutureBuilder automatically catches exceptions thrown by your Future and provides them in snapshot.error.

The beauty of this pattern is that it separates the HTTP logic from the UI logic while still providing a smooth user experience. The user gets immediate feedback (loading spinner) and clear success/error states without any additional complexity in your code.

19.9 Complete Example

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

Future<Album> createAlbum(String title) async {
  final response = await http.post(
    Uri.parse('https://jsonplaceholder.typicode.com/albums'),
    headers: <String, String>{
      'Content-Type': 'application/json; charset=UTF-8',
    },
    body: jsonEncode(<String, String>{'title': title}),
  );

  if (response.statusCode == 201) {
    // If the server did return a 201 CREATED response,
    // then parse the JSON.
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    // If the server did not return a 201 CREATED response,
    // then throw an exception.
    throw Exception('Failed to create album.');
  }
}

class Album {
  final int id;
  final String title;

  const Album({required this.id, required this.title});

  factory Album.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {'id': int id, 'title': String title} => Album(id: id, title: title),
      _ => throw const FormatException('Failed to load album.'),
    };
  }
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() {
    return _MyAppState();
  }
}

class _MyAppState extends State<MyApp> {
  final TextEditingController _controller = TextEditingController();
  Future<Album>? _futureAlbum;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Create Data Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: Scaffold(
        appBar: AppBar(title: const Text('Create Data Example')),
        body: Container(
          alignment: Alignment.center,
          padding: const EdgeInsets.all(8),
          child: (_futureAlbum == null) ? buildColumn() : buildFutureBuilder(),
        ),
      ),
    );
  }

  Column buildColumn() {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        TextField(
          controller: _controller,
          decoration: const InputDecoration(hintText: 'Enter Title'),
        ),
        ElevatedButton(
          onPressed: () {
            setState(() {
              _futureAlbum = createAlbum(_controller.text);
            });
          },
          child: const Text('Create Data'),
        ),
      ],
    );
  }

  FutureBuilder<Album> buildFutureBuilder() {
    return FutureBuilder<Album>(
      future: _futureAlbum,
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return Text(snapshot.data!.title);
        } else if (snapshot.hasError) {
          return Text('${snapshot.error}');
        }

        return const CircularProgressIndicator();
      },
    );
  }
}