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.
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 doctorThis 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 runflutter 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?".
Navigation is a stack you push and pop
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 doctoroutput properly. Most setup problems are already described there. buildreturns 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.
setStatemarks 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.builderfor anything list-shaped; a scrollingColumnbuilds 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.