Flutter for Cross-Platform Apps: Widgets, Layout, Navigation, HTTP and Riverpod State

Key takeaways

Flutter is Google's UI toolkit for building natively compiled apps for mobile, web, and desktop from a single Dart codebase. This guide covers the Flutter widget model, state management, navigation, and production deployment.

Flutter lets you write one Dart codebase that compiles to native iOS, Android, web, and desktop apps. This guide covers the widget system, state management patterns, navigation, HTTP, and deployment.

Real-world insight: A team shipped iOS and Android apps simultaneously in 8 weeks with Flutter — the consistent UI behavior across platforms eliminated an entire class of OS-specific bugs.

That consistency is worth understanding why it exists, since it’s the core architectural decision that separates Flutter from React Native: Flutter doesn’t wrap native iOS/Android UI components at all — it draws every pixel itself using its own rendering engine (Skia, or the newer Impeller), which means a button, a list, an animation looks and behaves identically on both platforms because it’s the exact same rendering code producing it, not two different native widget toolkits being bridged to. The tradeoff, covered in the FAQ above, is that this also means Flutter apps don’t automatically pick up OS-level UI updates (a new native design language shipped in an OS update) the way an app using real native components would — Flutter’s Material and Cupertino widget sets have to be updated separately to track platform design changes.


Setup

# Install Flutter SDK
# See flutter.dev/docs/get-started/install for your OS

# Verify installation
flutter doctor

# Create a new app
flutter create my_app
cd my_app
flutter run

flutter doctor is worth running before anything else and paying attention to, not just glancing past — it checks the entire toolchain (Flutter SDK, Android toolchain, Xcode for iOS, connected devices/emulators) and flags exactly what’s missing, which is the single most common source of confusing first-run failures for newcomers. flutter run hot-reloads by default while the process is running: saving a file pushes the change into the already-running app in under a second, preserving app state (which screen you’re on, what’s in a text field) — a meaningfully faster iteration loop than a native rebuild-and-relaunch cycle, and one of Flutter’s most-cited developer-experience advantages.


Everything Is a Widget

In Flutter, UI is built by composing widgets:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: const Center(
        child: Text('Hello, Flutter!', style: TextStyle(fontSize: 24)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }
}

Every widget class here extends StatelessWidget and every build method is const-friendly by design — Flutter’s widget tree is rebuilt frequently (on every state change anywhere in the affected subtree), and widgets are cheap, immutable configuration objects describing what the UI should look like at a point in time, not long-lived mutable objects Flutter mutates in place. This is conceptually the same model React popularized (a declarative tree rebuilt from data, diffed against the previous tree, with only the actual changes applied to the real render objects underneath) — which is worth knowing if you’re coming from React, since a lot of the intuition (avoid unnecessary rebuilds, keep widgets small and composable, const constructors let Flutter skip rebuilding widgets that haven’t changed) carries over directly.


StatefulWidget

Use StatefulWidget when the widget needs to rebuild on data changes:

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

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('$_count', style: Theme.of(context).textTheme.displayLarge),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

The split between StatefulWidget and its paired State object (_CounterWidgetState) is easy to gloss over but structurally important: the StatefulWidget itself (CounterWidget) is still immutable configuration, exactly like a StatelessWidget, while the actual mutable data (_count) and lifecycle live in the separate State object, which Flutter preserves across widget rebuilds even though the widget instance itself gets recreated. setState() isn’t just an assignment — calling it is literally what tells Flutter “the data this widget’s build() depends on changed, schedule a rebuild”; mutating _count directly without wrapping it in setState() would update the value but never trigger a re-render, leaving the UI visibly stale even though the underlying state is correct.


Layout Widgets

// Column (vertical) and Row (horizontal)
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    const Text('First'),
    const SizedBox(height: 8),
    const Text('Second'),
  ],
)

Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [
    const Text('Left'),
    ElevatedButton(onPressed: () {}, child: const Text('Right')),
  ],
)

// Expanded fills available space
Row(
  children: [
    const Icon(Icons.search),
    Expanded(
      child: TextField(decoration: InputDecoration(hintText: 'Search...')),
    ),
  ],
)

// Stack (z-axis layering)
Stack(
  children: [
    Image.network('https://picsum.photos/400/300'),
    Positioned(
      bottom: 16,
      left: 16,
      child: Text('Caption', style: TextStyle(color: Colors.white, fontSize: 18)),
    ),
  ],
)

Column/Row are Flutter’s Flexbox-equivalent primitives, and mainAxisAlignment/crossAxisAlignment map onto the same main-axis/cross-axis mental model CSS Flexbox uses — main axis is the direction the children flow (vertical for Column, horizontal for Row), cross axis is perpendicular to it. Expanded is worth understanding precisely because its behavior surprises people coming from CSS: it doesn’t just “grow to fill space” in a Row/Column context the way flex-grow implicitly might — a Row without an Expanded child will size itself to its children’s intrinsic width and can overflow (a common early-Flutter error, “A RenderFlex overflowed”), while wrapping one child in Expanded explicitly tells that child to consume whatever space remains after siblings are laid out. Stack/Positioned is the escape hatch for anything that doesn’t fit a linear layout model — absolute positioning within a bounding box, the same conceptual tool as CSS position: absolute inside a position: relative container.


Lists

// ListView.builder (lazy, efficient for large lists)
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    final item = items[index];
    return ListTile(
      leading: CircleAvatar(child: Text('${index + 1}')),
      title: Text(item.title),
      subtitle: Text(item.subtitle),
      trailing: const Icon(Icons.chevron_right),
      onTap: () => Navigator.pushNamed(context, '/detail', arguments: item),
    );
  },
)

// GridView
GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    crossAxisSpacing: 12,
    mainAxisSpacing: 12,
  ),
  itemCount: products.length,
  itemBuilder: (context, index) => ProductCard(product: products[index]),
)

The .builder constructor on both ListView and GridView is the detail worth internalizing over the plain ListView(children: [...]) form: instead of building every item widget up front, itemBuilder is called lazily, only for items that are actually visible (plus a small buffer off-screen) — this is what lets a list backed by thousands of items scroll smoothly without stalling on initial render, since Flutter only ever has to construct and lay out a handful of widgets at a time regardless of the underlying data’s total size. For a list you know is always short (a handful of settings items), the plain children: form is simpler and fine; .builder is specifically the tool for lists whose length you don’t want to bound.


// Named routes
MaterialApp(
  routes: {
    '/': (context) => const HomeScreen(),
    '/detail': (context) => const DetailScreen(),
    '/settings': (context) => const SettingsScreen(),
  },
)

// Navigate
Navigator.pushNamed(context, '/detail', arguments: item)
Navigator.pop(context)

// GoRouter (recommended for complex apps)
flutter pub add go_router
import 'package:go_router/go_router.dart';

final router = GoRouter(
  routes: [
    GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
    GoRoute(
      path: '/posts/:id',
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return PostDetailScreen(id: id);
      },
    ),
  ],
);

// Navigate
context.go('/posts/123')
context.push('/posts/123')  // pushes onto stack
context.pop()

Named routes (the first example) work fine for a small app with a flat set of screens, but they hit a real limitation the moment you need typed route parameters or deep linking (opening the app directly to /posts/123 from a push notification or a shared link) — the named-route API passes arguments as a loosely-typed Object? that has to be cast at the receiving end, with no compile-time guarantee the right type was passed. GoRouter’s URL-pattern-based routing (/posts/:id) is recommended specifically because it models navigation as real, parseable URLs from the start, which is what makes deep linking (mobile OS intents, universal links, and Flutter web’s actual browser URL bar) work naturally instead of as an afterthought bolted onto named routes. context.go vs context.push is a genuine behavioral difference worth getting right: go replaces the current location in history (like location.replace on web), while push adds a new entry you can navigate back from (like a normal link click) — using go where push was intended breaks the back button’s expected behavior.


HTTP and API Calls

flutter pub add http
import 'dart:convert';
import 'package:http/http.dart' as http;

class Post {
  final int id;
  final String title;
  final String body;

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

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(id: json['id'], title: json['title'], body: json['body']);
  }
}

Future<List<Post>> fetchPosts() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/posts'),
  );

  if (response.statusCode == 200) {
    final List<dynamic> json = jsonDecode(response.body);
    return json.map((j) => Post.fromJson(j)).toList();
  } else {
    throw Exception('Failed to load posts');
  }
}
// factory Post.fromJson exists because Dart has no built-in reflection-based
// JSON deserialization the way some languages do — jsonDecode() only ever
// produces plain Map<String, dynamic>/List<dynamic> from raw JSON, so every
// model class needs an explicit fromJson (and usually toJson) to convert
// between that loosely-typed map and a real, statically-typed Dart object.
// This is boilerplate-heavy by hand on a large app, which is why most real
// Flutter projects generate it via the json_serializable/freezed packages
// rather than hand-writing every model's fromJson/toJson.

// Use with FutureBuilder
FutureBuilder<List<Post>>(
  future: fetchPosts(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const CircularProgressIndicator();
    }
    if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    }
    final posts = snapshot.data!;
    return ListView.builder(
      itemCount: posts.length,
      itemBuilder: (context, i) => ListTile(title: Text(posts[i].title)),
    );
  },
)

FutureBuilder is the widget that bridges an imperative Future (the async fetchPosts() call) into Flutter’s declarative widget tree — it re-invokes its builder callback whenever the Future’s state changes (waiting → data or error), which is what lets loading spinners and error states fall naturally out of the same declarative model as everything else, rather than requiring manual setState() calls at each stage. Worth flagging a real gotcha though: future: fetchPosts() called inline inside build() re-triggers a new fetch on every rebuild of the parent, not just once — for anything more than a quick demo, the future should be created once (in initState() for a StatefulWidget, or via a provider as covered next) and stored, rather than called fresh every time build() runs.


State Management with Riverpod

flutter pub add flutter_riverpod
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Simple state provider
final counterProvider = StateProvider<int>((ref) => 0);

// Async provider (fetch data)
final postsProvider = FutureProvider<List<Post>>((ref) async {
  return fetchPosts();
});

// Main app setup
void main() {
  runApp(
    const ProviderScope(  // wrap app with ProviderScope
      child: MyApp(),
    ),
  );
}
// ProviderScope wrapping the whole app at the root is a structural
// requirement, not a suggestion — it's the container that actually holds
// provider state, and any provider read outside its subtree throws at
// runtime, which is a common first-error when adding Riverpod to an
// existing app and forgetting to wrap the root. StateProvider and
// FutureProvider solve the same two problems FutureBuilder/setState solved
// above, but as globally-accessible, cacheable state rather than state
// scoped to one widget's local lifecycle — the practical win is that any
// widget anywhere in the tree can ref.watch(postsProvider) and get the
// same shared data (and the same in-flight request, not a duplicate
// fetch) without threading it down through constructor parameters at
// every level.

// Use in widget (ConsumerWidget instead of StatelessWidget)
class CounterPage extends ConsumerWidget {
  const CounterPage({super.key});

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

    return Scaffold(
      body: Center(child: Text('$count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider.notifier).state++,
        child: const Icon(Icons.add),
      ),
    );
  }
}

class PostListPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final postsAsync = ref.watch(postsProvider);

    return postsAsync.when(
      loading: () => const CircularProgressIndicator(),
      error: (err, _) => Text('Error: $err'),
      data: (posts) => ListView.builder(
        itemCount: posts.length,
        itemBuilder: (context, i) => ListTile(title: Text(posts[i].title)),
      ),
    );
  }
}

ConsumerWidget replacing StatelessWidget (with the extra WidgetRef ref parameter in build) is Riverpod’s hook into the widget tree — ref.watch(counterProvider) both reads the current value and subscribes this specific widget to rebuild whenever that provider’s value changes, which is what makes ref.read(counterProvider.notifier).state++ in the button’s onPressed propagate to the Text('$count') automatically, without any setState() call anywhere. .when(loading:, error:, data:) on postsAsync is the async-provider equivalent of FutureBuilder’s connectionState checks — Riverpod models the three states (loading, error, data) as a proper sealed union the compiler forces you to exhaustively handle, rather than a nullable snapshot.data you have to remember to null-check yourself.


Common Widgets Cheatsheet

// Text
Text('Hello', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.blue))

// Image
Image.network('https://picsum.photos/200')
Image.asset('assets/images/logo.png')

// Icon
Icon(Icons.favorite, color: Colors.red, size: 32)

// Button variants
ElevatedButton(onPressed: () {}, child: Text('Elevated'))
TextButton(onPressed: () {}, child: Text('Text'))
OutlinedButton(onPressed: () {}, child: Text('Outlined'))
IconButton(icon: Icon(Icons.share), onPressed: () {})

// Input
TextField(
  controller: _controller,
  decoration: InputDecoration(
    labelText: 'Email',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(),
  ),
)

// Card
Card(
  elevation: 2,
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  child: Padding(padding: EdgeInsets.all(16), child: /* content */),
)

// Padding / Margin
Padding(padding: EdgeInsets.all(16), child: /* content */)
Container(margin: EdgeInsets.symmetric(horizontal: 16), child: /* content */)

Worth knowing the distinction between Padding and Container’s margin even though they visually look similar: Padding is a dedicated, lightweight widget that adds space inside its child’s bounds, while Container is a general-purpose, heavier widget that bundles together decoration (background color, border, border-radius), constraints, and margin all in one — reaching for Padding alone when you only need spacing (not decoration) is the more idiomatic, slightly cheaper choice, and it’s a common Flutter linting suggestion to flag a Container used only for its padding property.


Building and Deployment

# Development
flutter run                 # default device
flutter run -d chrome       # web
flutter run -d macos        # macOS

# Build
flutter build apk           # Android APK
flutter build appbundle     # Android (Play Store)
flutter build ios           # iOS (requires macOS + Xcode)
flutter build web           # Web

# Release build
flutter build apk --release

For App Store / Play Store submission, use Fastlane or Codemagic CI/CD to automate signing and upload. --release matters more than it looks: a debug build includes Dart’s development-mode assertions, hot-reload infrastructure, and unoptimized code, all of which make it noticeably larger and slower than a release build — submitting a debug APK/IPA to an app store (or benchmarking performance against one) is a common and misleading mistake that makes Flutter look slower than it actually is in production. appbundle (Android App Bundle) rather than a plain APK is Google Play’s required format for new app submissions specifically because it lets Play generate device-optimized APKs at install time (only shipping the resources/architecture a given device actually needs), which produces a meaningfully smaller download than one universal APK covering every device configuration.