NK

Search

Search pages, posts, and components

All posts
Mobile11 min read

How to Build Your First Flutter App

From an empty directory to a working app - setup, the counter template decoded, state, layout, navigation, and the first mistakes worth skipping.

flutterdartbeginnerstutorial

Most first-app tutorials hand you a finished file to paste and call it teaching. You end up with something that runs and no idea why. This one goes the other way: fewer features, more explanation of what each line is doing and which decisions matter later.

We will build a small notes app - add a note, see the list, tap through to a detail screen. That is deliberately unambitious. It touches state, layout, lists, and navigation, which is most of what an app actually is, without hiding any of it behind a package.

If you have never written Dart, you will still follow this. The language reads like a blend of TypeScript and Java, and nothing below relies on a feature you would need to look up first.

Getting a project running

Before any code, two things need to work: the SDK and a device.

Install the SDK and check the doctor

Install Flutter from the official docs for your platform, then run:

flutter doctor

This is the most useful command in the toolchain, and it is worth reading its output rather than glancing at it. It checks the SDK, the Android toolchain, the iOS toolchain (macOS only), and your editor plugins - and it tells you exactly what is missing.

Almost every "Flutter isn't working" question comes down to something doctor already reported. A red X next to Android toolchain means you have not accepted the Android licences; a warning about Xcode means the command-line tools are not selected. Fix those in the order it lists them.

You need at least one target working: an Android emulator, an iOS simulator, or Chrome. Chrome is the fastest way to start, but do get a real device or emulator running early - web has quirks that will confuse you about what is a Flutter problem and what is a browser one.

Create the project

flutter create notes_app
cd notes_app
flutter run

flutter create scaffolds a full project: Dart source in lib/, plus native Android and iOS host projects, tests, and configuration. You can ignore android/ and ios/ entirely for now - they exist so your Dart code has somewhere to run, and you will not touch them until you add a plugin that needs native configuration.

The only directory that matters today is lib/, and the only file in it is main.dart.

Get hot reload working before you write anything

With the app running, change a string in main.dart and save. The change should appear in under a second without losing your place in the app.

Do this before writing real code, because the entire workflow below assumes it. If a change is not appearing, the fix is nearly always one of three things: a compile error in the terminal you have not read, an edit inside initState or a field initialiser (which does not re-run), or a structural change that requires a hot restart rather than a reload.

Reading the template, then replacing it

flutter create gives you a counter app. Rather than deleting it blindly, read it - it demonstrates the three things every Flutter app is made of.

The three parts of any Flutter app

import 'package:flutter/material.dart';
 
void main() {
  runApp(const NotesApp());
}
 
class NotesApp extends StatelessWidget {
  const NotesApp({super.key});
 
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Notes',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const NotesPage(),
    );
  }
}

Three ideas are doing all the work here.

runApp takes a widget and makes it the root of the tree. Everything on screen descends from it.

MaterialApp sets up the app-wide plumbing - theming, routing, text direction, localisation. You almost always want one at the root, even if you do not use Material's visual style.

build returns a description, not a thing. It returns what the UI should look like given the current state, and Flutter works out how to make the screen match. That distinction is worth internalising now, because it explains why build runs often and why that is fine.

The const on const NotesApp() is not decoration either: a const widget is reused rather than rebuilt, which lets Flutter skip whole subtrees. Add it wherever the analyser suggests it.

Stateless versus stateful

This is the first real decision, and it has a clean rule.

A StatelessWidget renders from its inputs. Give it the same arguments and it produces the same output. Most of your widgets should be this.

A StatefulWidget owns data that changes over the widget's lifetime, and needs to re-render when it does. It comes in two classes: the widget itself (immutable, holds the configuration) and a State object (persistent, holds the data).

class NotesPage extends StatefulWidget {
  const NotesPage({super.key});
 
  @override
  State<NotesPage> createState() => _NotesPageState();
}
 
class _NotesPageState extends State<NotesPage> {
  final _notes = <String>[];
 
  void _addNote(String text) {
    // setState tells Flutter this State's data changed, so rebuild it.
    setState(() => _notes.add(text));
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Notes')),
      body: _NotesList(notes: _notes),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _addNote('Note ${_notes.length + 1}'),
        child: const Icon(Icons.add),
      ),
    );
  }
}

The two-class split confuses everyone at first. The reason is that widgets are thrown away and rebuilt constantly, so state cannot live on them - it lives on the State, which survives.

setState does not update the UI. It marks this State as dirty and asks for a frame. Flutter then calls build again and reconciles the result. Mutating _notes without setState actually changes the list; the screen just never finds out.

Two habits worth forming immediately: keep the work inside setState to the mutation itself, and put state as low in the tree as it will go. A setState at the root rebuilds the app; the same call in a small leaf widget rebuilds a leaf.

Building the actual screens

Now the parts that make it a real app.

Layout is Column, Row, and knowing about constraints

Flutter layout is mostly three widgets: Column (vertical), Row (horizontal), and Stack (overlapping). Padding, alignment, and sizing are separate widgets you wrap around things.

class _NotesList extends StatelessWidget {
  const _NotesList({required this.notes});
 
  final List<String> notes;
 
  @override
  Widget build(BuildContext context) {
    if (notes.isEmpty) {
      return const Center(child: Text('No notes yet. Tap + to add one.'));
    }
 
    return ListView.builder(
      itemCount: notes.length,
      itemBuilder: (context, index) => ListTile(
        leading: const Icon(Icons.sticky_note_2_outlined),
        title: Text(notes[index]),
        onTap: () => Navigator.of(context).push(
          MaterialPageRoute(builder: (_) => NoteDetail(note: notes[index])),
        ),
      ),
    );
  }
}

ListView.builder matters more than it looks. It builds only the rows currently visible, so a list of ten thousand notes costs about the same as a list of ten. Building a Column of every item inside a SingleChildScrollView looks equivalent and constructs all of them - the first performance mistake nearly everyone makes.

The empty state is not padding either. An app that shows nothing on first launch reads as broken; one sentence fixes it.

You will meet a layout error early, and it will be this one:

Vertical viewport was given unbounded height

It means a scrollable was asked how tall it wants to be, and answered "as tall as my parent", which was itself unbounded. Flutter's layout rule is constraints go down, sizes go up - a parent tells a child how much room it has, the child picks a size within that. Expanded, Flexible, and SizedBox are all ways of answering the question "how much room, exactly?".

class NoteDetail extends StatelessWidget {
  const NoteDetail({super.key, required this.note});
 
  final String note;
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Note')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(note, style: Theme.of(context).textTheme.bodyLarge),
      ),
    );
  }
}

Navigator.push puts a screen on top of the stack; the back button pops it. You get the platform's back gesture and animation without asking.

Note Theme.of(context) - that is how you read anything app-wide: theme, media queries, localisation. It walks up the tree from the context you give it, so passing the wrong context is such a common early bug.

Named routes and go_router exist and are worth adopting once you have more than a handful of screens or need deep links. For three screens, push is not technical debt.

Getting text input in

The add button currently invents note text. A real one needs a field:

Future<void> _promptForNote() async {
  final controller = TextEditingController();
 
  final text = await showDialog<String>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('New note'),
      content: TextField(
        controller: controller,
        autofocus: true,
        decoration: const InputDecoration(hintText: 'Write something'),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('Cancel'),
        ),
        TextButton(
          onPressed: () => Navigator.pop(context, controller.text),
          child: const Text('Add'),
        ),
      ],
    ),
  );
 
  controller.dispose();
  if (text != null && text.trim().isNotEmpty) _addNote(text.trim());
}

Two details worth carrying forward. showDialog returns a Future that completes with whatever you pass to Navigator.pop - navigation returns values, which removes a lot of callback plumbing. And controllers must be disposed; they hold listeners, and leaking them is the most common source of a slow memory climb in a Flutter app.

What to learn next, and what to ignore for now

You now have an app that holds state, lays out a list, and navigates. The next three steps, in the order they pay off:

Persistence. Your notes vanish on restart. shared_preferences for simple values, drift or sqflite for anything relational.

State management, eventually. setState really is fine until state needs to be shared between screens that are not parent and child. When that happens, look at Riverpod or Bloc - not before. Reaching for one on day one means learning two things at once and understanding neither.

Layers. When the app grows past a few screens, separating business rules from widgets is what keeps it changeable.

And what to leave alone for now: custom render objects, platform channels, animations beyond AnimatedContainer, and architecture patterns. Every one of those is a real tool, and none of them helps you finish a first app.

Key takeaways

  • Read flutter doctor output properly. Most setup problems are already described there.
  • build returns a description, not a mutation - and that is why it runs often and why that is cheap.
  • Stateless renders from inputs; stateful owns data that changes. Default to stateless.
  • setState marks state dirty, it does not update the screen directly. Mutating without it changes the data and not the UI.
  • Put state as low in the tree as it will go - the smaller the subtree, the smaller the rebuild.
  • Use ListView.builder for anything list-shaped; a scrolling Column builds every item.
  • Read layout errors as constraint problems. Constraints go down, sizes go up.
  • Dispose your controllers. They hold listeners and leak quietly.

FAQ

Do I need a Mac to build a Flutter app?

Only to build and ship for iOS - that requires Xcode. Android, web, Windows, and Linux all work fine on other platforms, and you can develop the whole app on Windows or Linux and let a CI service do the iOS build.

Should I learn Dart before Flutter?

No. Dart is small and unsurprising, and you will absorb what you need from the Flutter code you write. Spend an hour on null safety and Future/async, because those two show up immediately, and skip the rest until you meet it.

Why are there so many nested brackets?

Because layout is expressed as widgets wrapping widgets rather than as properties, so nesting is the syntax. It gets much more readable when you extract subtrees into named widgets - which also gives you a performance benefit, since a const widget can skip rebuilding entirely.

Is setState bad practice?

No. It is the right tool for state owned by one widget, and plenty of production screens use nothing else. It becomes the wrong tool when several unrelated screens need the same data, and that is the signal to reach for something more.

Why did my UI not update when I changed a variable?

Because Flutter has no way to know it changed. Wrap the mutation in setState, or if the value lives outside the widget, use a state management solution that notifies listeners.

Should I use Material or Cupertino widgets?

Start with Material - it is the better-supported set, and it works acceptably on iOS. Cupertino is worth it when you specifically want iOS-native look and feel, and mixing them thoughtfully is normal.

How do I add a package?

flutter pub add package_name, then import it. Check the pub.dev score and the last publish date before you commit to one - Flutter's ecosystem moves quickly and abandoned packages are common.

Conclusion

A first Flutter app is not hard, but it is easy to learn in an order that makes it feel hard. The trap is bringing in state management, routing, and architecture before you have a screen that works, and then debugging four unfamiliar things at once.

Build the small version first. Get a list, a form, and a second screen working with nothing but setState and Navigator. Every heavier tool you meet later will make more sense because you will have felt the specific problem it solves.

Read more

When the app outgrows a few screens, these are the next questions in order: How to Structure a Scalable Flutter Application for layering, Bloc vs Riverpod for state, and How Flutter Rendering Actually Works for what is happening underneath the widgets you just wrote.