Every Flutter app with more than a few items needs a list. The question is which list widget to use โ and getting this wrong on a large dataset means your app builds hundreds of widgets at once, eating memory and causing jank.
Flutter’s answer is ListView.builder: a lazy-loading list that only builds the items currently visible on screen. It is the official recommendation for any long, dynamic, or API-driven list, and once you understand its two required parameters, almost every list in your app becomes easy to build.
This guide starts with the minimal example and builds up to real-world patterns: lists of objects, custom card widgets, dividers, pull-to-refresh, loading skeletons, basic pagination, and horizontal lists. Every common mistake has a working fix.
- Comfortable with StatefulWidget and setState() โ needed for any list that updates
- Understand Flutter Layout: Row, Column, and Expanded โ the
Expandedfix for list layout errors comes from here - Know basic Dart lists and maps โ
itemCount: myList.lengthuses these directly
1. ListView vs ListView.builder
Flutter has two primary list constructors. Understanding the difference is the first thing to get right before writing any list code:
| Feature | ListView |
ListView.builder |
|---|---|---|
| Renders items | All at once on first build | Only visible items, lazily on demand |
| Memory use | Higher โ all widgets exist at once | Efficient โ recycles off-screen widgets |
| Best for | Short, fixed lists (โค ~20 items) | Long, dynamic, or API-driven lists |
| Requires itemCount? | โ No โ pass children directly | โ Strongly recommended |
| Requires itemBuilder? | โ No | โ Yes โ required parameter |
// The lazy-loading mental model:
//
// ListView builds ALL items at once:
// Item 0, Item 1, Item 2 ... Item 999 โ all 1000 widgets created immediately
//
// ListView.builder builds ONLY visible items:
// Screen shows items 0โ7
// โ user scrolls โ
// Item 0 is recycled, Item 8 is built
// Only ~8 widgets exist in memory at any time regardless of list size
If your list comes from a variable (
List<String>, API response, database query) โ use ListView.builder. If you are hardcoding 3โ5 widgets โ ListView with a children: list is fine. When in doubt, use ListView.builder.
2. The Minimal Example
ListView.builder needs exactly two things: itemCount (how many items total) and itemBuilder (what to render for each index). The builder receives two arguments โ BuildContext context and int index โ where index starts at 0 and goes up to itemCount - 1:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: SimpleListPage()));
}
class SimpleListPage extends StatelessWidget {
const SimpleListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Simple List')),
body: ListView.builder(
itemCount: 20, // how many items to build
itemBuilder: (context, index) { // called once per visible item
return ListTile(
leading: CircleAvatar(child: Text('$index')),
title: Text('Item $index'),
subtitle: Text('Index starts at 0, ends at ${20 - 1}'),
);
},
),
);
}
}
If you omit
itemCount, Flutter treats the list as infinite โ itemBuilder is called forever with incrementing indexes. This can cause index-out-of-range crashes when your builder tries to access myList[index] beyond the list bounds, and creates unnecessary blank space below the real items.
3. Real Example with a Data List
Most apps render ListView.builder from a real List. The key is passing myList.length as itemCount and accessing myList[index] inside itemBuilder. Here is a complete example with a list of maps:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: FruitListPage()));
}
class FruitListPage extends StatelessWidget {
const FruitListPage({super.key});
// The data source โ in a real app this comes from an API or database
static const List<Map<String, dynamic>> fruits = [
{'name': 'Apple', 'emoji': '๐', 'color': Color(0xFFE53935)},
{'name': 'Banana', 'emoji': '๐', 'color': Color(0xFFFDD835)},
{'name': 'Cherry', 'emoji': '๐', 'color': Color(0xFFD81B60)},
{'name': 'Grape', 'emoji': '๐', 'color': Color(0xFF8E24AA)},
{'name': 'Mango', 'emoji': '๐ฅญ', 'color': Color(0xFFFB8C00)},
{'name': 'Kiwi', 'emoji': '๐ฅ', 'color': Color(0xFF43A047)},
{'name': 'Lemon', 'emoji': '๐', 'color': Color(0xFFF9A825)},
{'name': 'Watermelon', 'emoji': '๐', 'color': Color(0xFF00897B)},
{'name': 'Peach', 'emoji': '๐', 'color': Color(0xFFFF7043)},
{'name': 'Blueberry', 'emoji': '๐ซ', 'color': Color(0xFF1E88E5)},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Fruits (${fruits.length})'),
),
body: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: fruits.length, // โ always use list.length
itemBuilder: (context, index) {
final fruit = fruits[index]; // โ access the item at this index
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: fruit['color'] as Color,
child: Text(
fruit['emoji'] as String,
style: const TextStyle(fontSize: 20),
),
),
title: Text(
fruit['name'] as String,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text('Item ${index + 1} of ${fruits.length}'),
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Tapped: ${fruit['name']}'),
duration: const Duration(seconds: 1),
),
);
},
),
);
},
),
);
}
}
4. Custom Card Item Widget
As list items get more complex, keeping all the UI inside itemBuilder makes it hard to read and maintain. Extract each item into its own widget. This keeps itemBuilder to a single line and lets Flutter optimise rebuilds more precisely:
import 'package:flutter/material.dart';
// 1. Define a typed data model โ better than Map<String, dynamic>
class Product {
final String name;
final String price;
final String category;
final IconData icon;
final bool inStock;
const Product({
required this.name,
required this.price,
required this.category,
required this.icon,
this.inStock = true,
});
}
void main() {
runApp(const MaterialApp(home: ProductListPage()));
}
class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});
static const List<Product> products = [
Product(name: 'Wireless Headphones', price: 'โน1,999', category: 'Audio', icon: Icons.headphones),
Product(name: 'Mechanical Keyboard', price: 'โน3,499', category: 'Computing', icon: Icons.keyboard),
Product(name: 'USB-C Hub', price: 'โน1,299', category: 'Computing', icon: Icons.usb),
Product(name: 'Monitor Stand', price: 'โน899', category: 'Desk', icon: Icons.monitor),
Product(name: 'Webcam HD', price: 'โน2,199', category: 'Video', icon: Icons.videocam, inStock: false),
Product(name: 'Laptop Sleeve', price: 'โน599', category: 'Bags', icon: Icons.work),
Product(name: 'Desk Lamp LED', price: 'โน749', category: 'Lighting', icon: Icons.lightbulb),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: products.length,
// 2. itemBuilder is now a single clean line
itemBuilder: (context, index) => ProductCard(product: products[index]),
),
);
}
}
// 3. The extracted item widget โ all card UI lives here
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
// Icon container
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: scheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(product.icon, color: scheme.primary, size: 28),
),
const SizedBox(width: 16),
// Text content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
Text(
product.category,
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
],
),
),
// Price + stock badge
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
product.price,
style: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: product.inStock
? Colors.green.shade100
: Colors.red.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
product.inStock ? 'In stock' : 'Out of stock',
style: TextStyle(
fontSize: 11,
color: product.inStock ? Colors.green.shade800 : Colors.red.shade800,
),
),
),
],
),
],
),
),
);
}
}
5. ListView.separated โ Dividers and Gaps
ListView.separated is a variant that adds a separator widget between each item. It requires one extra parameter โ separatorBuilder โ and renders it between every pair of adjacent items (so 10 items get 9 separators):
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: ContactListPage()));
}
class ContactListPage extends StatelessWidget {
const ContactListPage({super.key});
static const List<Map<String, String>> contacts = [
{'name': 'Alice Johnson', 'role': 'Designer', 'initial': 'A'},
{'name': 'Bob Smith', 'role': 'Developer', 'initial': 'B'},
{'name': 'Carol White', 'role': 'Product Manager', 'initial': 'C'},
{'name': 'David Lee', 'role': 'QA Engineer', 'initial': 'D'},
{'name': 'Eva Martinez', 'role': 'DevOps', 'initial': 'E'},
{'name': 'Frank Chen', 'role': 'Data Scientist', 'initial': 'F'},
{'name': 'Grace Kim', 'role': 'iOS Developer', 'initial': 'G'},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Contacts')),
body: ListView.separated(
itemCount: contacts.length,
// The list item
itemBuilder: (context, index) {
final contact = contacts[index];
return ListTile(
leading: CircleAvatar(
child: Text(contact['initial']!),
),
title: Text(contact['name']!),
subtitle: Text(contact['role']!),
trailing: const Icon(Icons.message_outlined),
onTap: () {},
);
},
// The separator between items
separatorBuilder: (context, index) => const Divider(
height: 1,
indent: 72, // aligns with the text, not the avatar
endIndent: 16,
),
),
);
}
}
// โโ Other separator options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Option 2: Vertical gap between cards
separatorBuilder: (context, index) => const SizedBox(height: 8),
// Option 3: Section headers every 5 items
separatorBuilder: (context, index) {
if ((index + 1) % 5 == 0) {
return Container(
color: Colors.grey.shade100,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Section ${(index + 1) ~/ 5 + 1}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
);
}
return const Divider(height: 1);
},
6. Stateful Lists โ Adding and Removing Items
When the list changes โ items added, deleted, or reordered โ the widget must be StatefulWidget and every mutation must happen inside setState(). Here is a complete example with add and swipe-to-delete:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: TodoListPage()));
}
class TodoListPage extends StatefulWidget {
const TodoListPage({super.key});
@override
State<TodoListPage> createState() => _TodoListPageState();
}
class _TodoListPageState extends State<TodoListPage> {
final List<String> _items = ['Buy groceries', 'Walk the dog', 'Read Flutter docs'];
final TextEditingController _controller = TextEditingController();
void _addItem() {
final text = _controller.text.trim();
if (text.isEmpty) return;
setState(() { // โ always mutate inside setState
_items.add(text);
});
_controller.clear();
}
void _removeItem(int index) {
final removed = _items[index];
setState(() {
_items.removeAt(index);
});
// Undo snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('"$removed" removed'),
action: SnackBarAction(
label: 'Undo',
onPressed: () => setState(() => _items.insert(index, removed)),
),
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('To-Do (${_items.length})'),
),
body: Column(
children: [
// Add item field
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Add a task...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
onSubmitted: (_) => _addItem(),
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text('Add'),
),
],
),
),
// The list
Expanded(
child: _items.isEmpty
? const Center(child: Text('No tasks yet โ add one above!'))
: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
return Dismissible(
key: ValueKey(_items[index]), // unique key required
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red,
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => _removeItem(index),
child: ListTile(
leading: const Icon(Icons.check_box_outline_blank),
title: Text(_items[index]),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _removeItem(index),
),
),
);
},
),
),
],
),
);
}
}
7. Pull-to-Refresh with RefreshIndicator
Wrapping ListView.builder in a RefreshIndicator adds the standard pull-to-refresh gesture. The onRefresh callback must return a Future โ the spinner shows until the future completes:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: RefreshListPage()));
}
class RefreshListPage extends StatefulWidget {
const RefreshListPage({super.key});
@override
State<RefreshListPage> createState() => _RefreshListPageState();
}
class _RefreshListPageState extends State<RefreshListPage> {
List<String> _items = List.generate(10, (i) => 'Original item ${i + 1}');
// Simulates an API refresh โ replace with your real fetch logic
Future<void> _onRefresh() async {
await Future.delayed(const Duration(seconds: 2)); // simulate network delay
setState(() {
final timestamp = DateTime.now().minute;
_items = List.generate(10, (i) => 'Refreshed at :$timestamp โ item ${i + 1}');
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pull to Refresh')),
body: RefreshIndicator(
onRefresh: _onRefresh, // โ must return Future<void>
child: ListView.builder(
// physics must allow overscroll for pull-to-refresh to work
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length,
itemBuilder: (context, index) => ListTile(
leading: const Icon(Icons.article_outlined),
title: Text(_items[index]),
),
),
),
);
}
}
If the list has fewer items than the screen height, it won’t scroll by default โ which means
RefreshIndicator‘s pull gesture won’t trigger. Set physics: const AlwaysScrollableScrollPhysics() to ensure the pull gesture always works, even on short lists.
8. Loading State and Empty State
Real apps fetch data asynchronously. The list needs to handle three states: loading (show a spinner or skeleton), empty (show a helpful message), and loaded (show the list). Here is the full pattern:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: AsyncListPage()));
}
class AsyncListPage extends StatefulWidget {
const AsyncListPage({super.key});
@override
State<AsyncListPage> createState() => _AsyncListPageState();
}
class _AsyncListPageState extends State<AsyncListPage> {
List<String> _items = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
// Simulate a 1.5-second network fetch
await Future.delayed(const Duration(milliseconds: 1500));
setState(() {
_items = List.generate(12, (i) => 'Post ${i + 1}: Flutter tip of the day');
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'Failed to load data. Tap to retry.';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Async List')),
body: _buildBody(),
);
}
Widget _buildBody() {
// 1. Loading state โ spinner while fetch is in progress
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
// 2. Error state โ tap to retry
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.wifi_off, size: 56, color: Colors.grey),
const SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _loadData,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
// 3. Empty state โ no data after a successful load
if (_items.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No posts yet', style: TextStyle(fontSize: 18)),
],
),
);
}
// 4. Loaded state โ the actual list
return RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length,
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(_items[index]),
subtitle: Text('Published today'),
),
),
);
}
}
9. Basic Pagination โ Load More on Scroll
For large datasets, loading all items at once wastes bandwidth. Detect when the user scrolls near the bottom using a ScrollController, then fetch the next page:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: PaginatedListPage()));
}
class PaginatedListPage extends StatefulWidget {
const PaginatedListPage({super.key});
@override
State<PaginatedListPage> createState() => _PaginatedListPageState();
}
class _PaginatedListPageState extends State<PaginatedListPage> {
final ScrollController _scrollController = ScrollController();
final List<String> _items = [];
bool _isLoadingMore = false;
bool _hasMore = true;
int _page = 0;
@override
void initState() {
super.initState();
_loadNextPage(); // load the first page immediately
_scrollController.addListener(_onScroll);
}
void _onScroll() {
// Fire when the user scrolls within 200px of the bottom
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
if (currentScroll >= maxScroll - 200 && !_isLoadingMore && _hasMore) {
_loadNextPage();
}
}
Future<void> _loadNextPage() async {
if (_isLoadingMore) return;
setState(() => _isLoadingMore = true);
await Future.delayed(const Duration(milliseconds: 800)); // simulate API
final newItems = List.generate(
10,
(i) => 'Page ${_page + 1} โ Item ${_page * 10 + i + 1}',
);
setState(() {
_items.addAll(newItems);
_page++;
_isLoadingMore = false;
if (_page >= 5) _hasMore = false; // stop after 5 pages (50 items total)
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Paginated List (${_items.length} items)')),
body: ListView.builder(
controller: _scrollController, // โ attach the scroll controller
// Add 1 extra item at the end for the loading indicator
itemCount: _items.length + (_isLoadingMore || _hasMore ? 1 : 0),
itemBuilder: (context, index) {
// Last item โ show spinner if loading, or "end of list" message
if (index == _items.length) {
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: _isLoadingMore
? const CircularProgressIndicator()
: const Text(
'You have reached the end',
style: TextStyle(color: Colors.grey),
),
),
);
}
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(_items[index]),
);
},
),
);
}
}
10. Horizontal Lists and Grid Alternatives
ListView.builder scrolls vertically by default. Set scrollDirection: Axis.horizontal for a horizontal list โ common for category chips, featured cards, or image carousels:
// Horizontal scrolling list โ each item needs a fixed width
SizedBox(
height: 120, // โ height must be bounded for horizontal lists
child: ListView.builder(
scrollDirection: Axis.horizontal, // โ makes it scroll left/right
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return Container(
width: 100, // โ fixed width for each item
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
color: category['color'] as Color,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(category['icon'] as IconData, color: Colors.white, size: 36),
const SizedBox(height: 8),
Text(
category['name'] as String,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
],
),
);
},
),
)
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Combining horizontal featured cards + vertical full list on one screen
ListView(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 0, 8),
child: Text('Featured', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
// Horizontal list embedded in the vertical scroll
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 5,
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.only(right: 12),
child: SizedBox(
width: 140,
child: Center(child: Text('Featured $index')),
),
),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 0, 8),
child: Text('All Items', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
// Regular vertical items below
...List.generate(10, (i) => ListTile(title: Text('Item $i'))),
],
)
11. shrinkWrap and Nested Lists
The most common layout error with ListView.builder is putting it inside a Column without constraining its height. There are two fixes โ choose based on your use case:
// โ Wrong โ throws "Vertical viewport was given unbounded height"
Column(
children: [
const Text('My List'),
ListView.builder( // no height constraint โ Flutter can't render this
itemCount: 10,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
],
)
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// โ
Fix 1: Expanded โ preferred for most cases
// ListView fills all remaining space in the Column
Column(
children: [
const Text('My List'),
Expanded( // โ gives ListView bounded height
child: ListView.builder(
itemCount: 10,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
),
],
)
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// โ
Fix 2: shrinkWrap โ for short embedded lists only
// ListView shrinks to fit its content exactly
Column(
children: [
const Text('My List'),
ListView.builder(
shrinkWrap: true, // โ sizes to content
physics: const NeverScrollableScrollPhysics(), // โ parent handles scrolling
itemCount: 5, // keep this small
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
const Text('More content below the list'),
],
)
When
shrinkWrap: true, Flutter must build ALL items at once to know the total height. This cancels the entire performance benefit of ListView.builder. Only use shrinkWrap for short, fixed lists (less than ~20 items). For any longer list, use Expanded instead.
12. Common Beginner Mistakes
Mistake 1: Missing itemCount โ infinite list and index crash
// โ Wrong โ no itemCount โ infinite list, index goes beyond myList.length โ crash
ListView.builder(
itemBuilder: (context, index) => Text(myList[index]), // RangeError!
)
// โ
Correct โ itemCount bounds the index to valid list positions
ListView.builder(
itemCount: myList.length,
itemBuilder: (context, index) => Text(myList[index]),
)
Mistake 2: ListView inside Column without Expanded
// โ Wrong โ throws "Vertical viewport was given unbounded height"
Column(
children: [
ListView.builder(itemCount: 10, itemBuilder: ...),
],
)
// โ
Correct
Column(
children: [
Expanded(
child: ListView.builder(itemCount: 10, itemBuilder: ...),
),
],
)
Mistake 3: Mutating the list without setState โ UI never updates
// โ Wrong โ list changes in memory but Flutter doesn't rebuild
void addItem() {
myList.add('New Item'); // value changes but UI stays the same
}
// โ Also wrong โ setState called in the wrong widget (child instead of owner)
// The widget holding ListView.builder doesn't rebuild
// โ
Correct โ always mutate inside setState in the widget that owns the list
void addItem() {
setState(() {
myList.add('New Item'); // Flutter rebuilds ListView.builder with the new data
});
}
Mistake 4: Missing key on Dismissible โ wrong items deleted
// โ Wrong โ using index as key causes Flutter to misidentify items after deletion
Dismissible(
key: ValueKey(index), // โ index changes after deletion โ wrong item removed
child: ...,
)
// โ
Correct โ use a unique identifier from the data itself
Dismissible(
key: ValueKey(myList[index].id), // โ stable ID tied to the item, not its position
child: ...,
)
Mistake 5: Hardcoding index instead of using the builder parameter
// โ Wrong โ ignoring the index parameter and hardcoding a value
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => Text(items[0].name), // always shows item 0!
)
// โ
Correct โ use the index parameter that itemBuilder provides
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => Text(items[index].name), // โ index changes each call
)
13. Interview Q&A
A: It is a
ListView constructor that builds list items lazily โ only when they are about to scroll into view. Flutter calls itemBuilder on demand for visible items only and recycles widgets that scroll off screen. This makes it the correct choice for long, dynamic, or API-driven lists where building all items at once would waste memory and slow down the app.
A:
ListView renders all its children at once on first build โ fine for 5โ20 static items. ListView.builder only renders visible items on demand and recycles them as the user scrolls โ essential for 50+ items or any dynamic list. Think of ListView as printing every page of a book up front, versus ListView.builder printing only the page you are currently reading.
A: Only
itemBuilder is strictly required by the API โ it is a function that receives (BuildContext context, int index) and returns a widget. itemCount is not technically required, but should always be set for data-driven lists. Without it, Flutter treats the list as infinite and itemBuilder will be called beyond your list’s bounds, causing a RangeError crash.
A: Because
ListView.builder inside a Column has no bounded height constraint โ the column gives it infinite vertical space and the list doesn’t know how tall to be. Fix it with Expanded (preferred โ list fills remaining space) or shrinkWrap: true with NeverScrollableScrollPhysics() (use only for short embedded lists, as it disables lazy loading).
A:
shrinkWrap: true makes the ListView size itself to its total content height instead of filling all available space. This is useful for short lists embedded inside a Column or SingleChildScrollView. However, it forces Flutter to build ALL items immediately to calculate the total height, which disables the lazy-loading behaviour that makes ListView.builder performant. Avoid it for lists longer than ~20 items โ use Expanded instead.
A: It is a variant of
ListView.builder that renders a separator widget between each item. It requires a separatorBuilder parameter alongside itemBuilder and itemCount. The separator can be a Divider, a SizedBox for spacing, or any widget. For 10 items, it renders 9 separators โ one between each adjacent pair.
14. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Widgets Explained: Stateless vs Stateful | Stateful lists (add, remove, refresh) depend entirely on StatefulWidget and setState() โ this is the prerequisite. |
| Flutter Layout Made Easy: Row, Column, Flex and Expanded | Explains Expanded โ the fix for the most common ListView.builder layout error. |
| Flutter Search Bar from Scratch | Builds directly on ListView.builder โ add real-time filtering to any list built here. |
| Handling User Input: TextField, Forms and Validation | The add-item TextField in the stateful list section uses these exact patterns. |
| Building a Notes App โ Part 1 | A real project that puts ListView.builder, Dismissible, and setState together in a working app. |
