Advanced ListView Optimization in Flutter with Riverpod: Avoiding Unnecessary Rebuilds
Introduction
When building Flutter applications with dynamic lists, performance can quickly become an issue if not handled properly. A common problem is that when one item in a list changes, the entire ListView and all its items rebuild, causing unnecessary performance overhead and janky animations.
In this article, we'll explore advanced optimization techniques using Riverpod to ensure that only the affected list item rebuilds when its data changes, while the rest of the list remains untouched.
The Problem
Consider a typical todo list application. When you toggle the completion status of one task, what happens?
Without optimization:
The entire tasks list state changes
The ListView rebuilds
All TodoItem widgets rebuild
Performance degrades with larger lists
What we want:
Only the toggled TodoItem rebuilds
The ListView structure remains stable
Other items are completely unaffected
The Solution: Family Providers + Select
We'll use two powerful Riverpod features:
AsyncNotifierProvider - For managing mutable async state
Family Providers - For creating provider instances per list item
Select modifier - For watching only specific parts of state
Implementation
Step 1: Create the Task Model
class Task {
final int id;
final String title;
final bool completed;
const Task({
required this.id,
required this.title,
this.completed = false,
});
Task copyWith({
int? id,
String? title,
bool? completed,
}) {
return Task(
id: id ?? this.id,
title: title ?? this.title,
completed: completed ?? this.completed,
);
}
}
Step 2: Set Up AsyncNotifierProvider
// AsyncNotifierProvider for managing tasks state
final tasksProvider = AsyncNotifierProvider<TasksNotifier, List<Task>>(
() => TasksNotifier(),
);
class TasksNotifier extends AsyncNotifier<List<Task>> {
@override
Future<List<Task>> build() async {
// Initial load of tasks
return await fetchTasks();
}
// Toggle task completion status
Future<void> toggleTaskCompletion(Task newTask, int index) async {
final currentState = state;
if (!currentState.hasValue) return;
final tasks = currentState.value!;
// IMPORTANT: Create a NEW list (immutability)
final updatedTasks = [...tasks];
updatedTasks[index] = newTask;
// Update state with the NEW list reference
state = AsyncValue.data(updatedTasks);
// Simulate API call
await Future.delayed(const Duration(milliseconds: 300));
}
}
Key Point: We create a new list using the spread operator [...tasks]. This is crucial because Riverpod uses reference equality to detect changes. Mutating the existing list won't trigger updates.
Step 3: Create a Family Provider
// Family provider for individual task by index
final taskByIndexProvider = Provider.family<Task?, int>((ref, index) {
final asyncTasks = ref.watch(tasksProvider);
return asyncTasks.whenOrNull(
data: (tasks) => index < tasks.length ? tasks[index] : null,
);
});
This creates a separate provider instance for each index. taskByIndexProvider(0) is different from taskByIndexProvider(1), and, thus, they watch different parts of the state.
Step 4: Optimize the ListView
class TasksScreen extends ConsumerWidget {
const TasksScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch ONLY the list length using select
final tasksLength = ref.watch(
tasksProvider.select((asyncValue) => asyncValue.value?.length),
);
return Scaffold(
appBar: AppBar(title: const Text('Optimized ListView')),
body: ref.watch(tasksProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (tasks) {
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
// Pass only the index
return TodoItem(index: index);
},
);
},
),
);
}
}
Important: The select modifier on line 9-11 ensures the ListView only rebuilds when the list length changes (items added/removed), not when individual items change.
The ListView is built when AND ONLY WHEN:
it is built the first time (at this time, the
AsyncNotifierProvideris fetching data the first time and soasyncValue.valueis null at this time)the list of tasks is successfully fetched (
asyncValue.value?.lengthreturns anint)when any item is added or removed (i.e. when the length of the list of tasks changes)
Understanding asyncValue.value?.length:
Returns the length when data is available
Returns
nullduring initial loading (no previous data)Returns previous length during refresh (cached data available)
Returns
nullon error with no cached data
Step 5: Create the Optimized TodoItem
class TodoItem extends ConsumerWidget {
final int index;
const TodoItem({
super.key,
required this.index,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the family provider for this specific index
final task = ref.watch(taskByIndexProvider(index));
if (task == null) {
return const SizedBox.shrink();
}
debugPrint('Building TodoItem for index: $index');
return ListTile(
title: Text(task.title),
trailing: Checkbox(
value: task.completed,
onChanged: (value) {
ref
.read(tasksProvider.notifier)
.toggleTaskCompletion(
task.copyWith(completed: value!),
index,
);
},
),
);
}
}
Key Points:
Only receives
indexas a parameterWatches
taskByIndexProvider(index)- its own dedicated providerOnly rebuilds when the task at that specific index changes
How It Works: The Magic Behind the Optimization
Visual Flow Diagram
Here's a complete step-by-step visualization of how the optimization works:
┌─────────────────────────────────────────────────────────────────────────────┐
│ INITIAL SETUP: ListView builds visible items │
└─────────────────────────────────────────────────────────────────────────────┘
ListView.builder (only builds visible items + cacheExtent ~250px)
│
├─> TodoItem(index: 0)
│ │
│ └─> ref.watch(taskByIndexProvider(0))
│ │
│ └─> taskByIndexProvider(0) watches tasksProvider
│ │
│ └─> Returns tasks[0]
│
├─> TodoItem(index: 1)
│ │
│ └─> ref.watch(taskByIndexProvider(1))
│ │
│ └─> taskByIndexProvider(1) watches tasksProvider
│ │
│ └─> Returns tasks[1]
│
└─> TodoItem(index: 2)
│
└─> ref.watch(taskByIndexProvider(2))
│
└─> taskByIndexProvider(2) watches tasksProvider
│
└─> Returns tasks[2]
Note: If list has 50 items but only 10 visible on screen,
only ~10-15 family providers exist (visible + cacheExtent)
┌─────────────────────────────────────────────────────────────────────────────┐
│ USER ACTION: Toggle checkbox at index 2 │
└─────────────────────────────────────────────────────────────────────────────┘
User clicks checkbox
│
└─> TodoItem(index: 2).onChanged()
│
└─> tasksProvider.notifier.toggleTaskCompletion(newTask, 2)
│
└─> Creates NEW list: [...tasks]
└─> Updates tasks[2] = newTask
└─> state = AsyncValue.data(updatedTasks)
┌─────────────────────────────────────────────────────────────────────────────┐
│ STATE UPDATE: tasksProvider notifies all active family providers │
└─────────────────────────────────────────────────────────────────────────────┘
tasksProvider (state changed: new list reference)
│
├─> Notifies: taskByIndexProvider(0)
│ │
│ └─> RECOMPUTES: tasks[0]
│ └─> ref.watch checks: tasks[0] == oldTasks[0]?
│ │
│ └─> ✅ TRUE (same object, immutable)
│ └─> ref.watch does NOT call build()
│ └─> TodoItem(index: 0) NOT rebuilt
│
├─> Notifies: taskByIndexProvider(1)
│ │
│ └─> RECOMPUTES: tasks[1]
│ └─> ref.watch checks: tasks[1] == oldTasks[1]?
│ │
│ └─> ✅ TRUE (same object, immutable)
│ └─> ref.watch does NOT call build()
│ └─> TodoItem(index: 1) NOT rebuilt
│
└─> Notifies: taskByIndexProvider(2)
│
└─> RECOMPUTES: tasks[2]
└─> ref.watch checks: tasks[2] == oldTasks[2]?
│
└─> ❌ FALSE (different object from copyWith)
└─> ref.watch CALLS build()
└─> TodoItem(index: 2).build() executed
└─> UI updates for this item only!
┌─────────────────────────────────────────────────────────────────────────────┐
│ RESULT: Only TodoItem(index: 2) rebuilt, others unchanged │
└─────────────────────────────────────────────────────────────────────────────┘
ListView: NOT rebuilt (length unchanged, watched via select)
TodoItem(index: 0): NOT rebuilt (ref.watch equality check passed)
TodoItem(index: 1): NOT rebuilt (ref.watch equality check passed)
TodoItem(index: 2): ✅ REBUILT (ref.watch equality check failed)
1. Family Provider Creates Isolated Watchers
When you call taskByIndexProvider(0), Riverpod creates a provider instance specifically for index 0. This instance:
Watches the main
tasksProviderExtracts only
tasks[0]Notifies listeners only when
tasks[0]changes
Important: ListView cacheExtent
Flutter's ListView.builder doesn't create widgets for all items in the list. It only creates widgets for:
Items currently visible in the viewport
Items within the
cacheExtent(default ~250 pixels before and after the viewport)
This means if you have 50 tasks but only 10 are visible on screen, only about 10-15 family provider instances are created and active. As you scroll, new family providers are created for newly visible items, and old ones are disposed.
2. Reference Equality Detection
// ❌ WRONG - Mutating the list
tasks[index] = newTask;
state = AsyncValue.data(tasks); // Same reference!
// ✅ CORRECT - Creating new list
final updatedTasks = [...tasks];
updatedTasks[index] = newTask;
state = AsyncValue.data(updatedTasks); // New reference!
Riverpod compares list references. When you create a new list, Riverpod detects the change and notifies all watchers. The family providers then check if their specific index changed.
How Task Equality Works:
When a family provider receives a notification that the tasks list changed, it compares the task at its index:
// In taskByIndexProvider(2)
final oldTask = oldTasks[2]; // Task(id: 2, title: "Task 3", completed: false)
final newTask = newTasks[2]; // Task(id: 2, title: "Task 3", completed: true)
// Riverpod checks: oldTask == newTask ?
Two scenarios for equality checking:
With overridden
==operator (our implementation):@override bool operator ==(Object other) { if (identical(this, other)) return true; return other is Task && other.id == id && other.title == title && other.completed == completed; }Compares field values
Returns
falseifcompletedchangedTriggers rebuild
Without overridden
==operator (default Dart behavior):Uses identity comparison (
identical(this, other))Since Task fields are
final, the class is immutableWhen we do
task.copyWith(completed: true), a new Task object is createdThe new object has a different memory address
identical(oldTask, newTask)returnsfalseTriggers rebuild
Both approaches work! The key is that copyWith creates a new object, ensuring the equality check fails for the changed task.
3. Selective Rebuilds
When you toggle task at index 2:
toggleTaskCompletioncreates a new list with updated task at index 2tasksProvidernotifies all active family provider listeners (only those for visible items + cacheExtent)All active family providers recompute their state:
taskByIndexProvider(0)recomputes → returnstasks[0]taskByIndexProvider(1)recomputes → returnstasks[1]taskByIndexProvider(2)recomputes → returnstasks[2]
ref.watchperforms equality checks:In
TodoItem(index: 0):ref.watchcheckstasks[0] == oldTasks[0]?Same Task object (immutable, same memory address)
OR same field values (if
==overridden)Result:
true→ref.watchdoes NOT callbuild()
In
TodoItem(index: 1):ref.watchcheckstasks[1] == oldTasks[1]?Same Task object
Result:
true→ref.watchdoes NOT callbuild()
In
TodoItem(index: 2):ref.watchcheckstasks[2] == oldTasks[2]?Different Task object (created by
copyWith)OR different field values (
completedchanged)Result:
false→ref.watchCALLSbuild()
ListView doesn't rebuild (length didn't change, watched via
select)
Key Insights:
Only family providers for visible items exist. If you have 50 tasks but only 10 visible, only ~10-15 family providers are active.
All active family providers recompute when
tasksProviderchangesBut
ref.watchonly callsbuild()when the equality check fails (previous != new)This is why immutability is crucial - unchanged tasks remain the same object in memory
4. Understanding ListView cacheExtent
Flutter's ListView.builder is lazy - it only builds widgets that are needed. Here's how it works:
Viewport: The visible area on screen cacheExtent: Extra area before and after viewport (default: 250 pixels)
┌─────────────────────────┐
│ Cache Area (250px) │ ← Items built but not visible
├─────────────────────────┤
│ │
│ Visible Viewport │ ← Items visible on screen
│ (Items 5-14) │
│ │
├─────────────────────────┤
│ Cache Area (250px) │ ← Items built but not visible
└─────────────────────────┘
Items 0-4: Not built yet (above cache)
Items 5-6: Built (in cache area)
Items 7-14: Built and visible
Items 15-16: Built (in cache area)
Items 17-50: Not built yet (below cache)
What this means for our optimization:
50 total tasks in the list
10 visible on screen
~15 total widgets built (visible + cache)
~15 family providers created and active
~15 equality checks when state updates
As you scroll:
New items enter the cache → New family providers created
Old items leave the cache → Old family providers disposed
Only active family providers receive notifications
This is why the optimization scales beautifully even with thousands of items!
Alternative Approach: Using Select Instead of Family
You can also use the select modifier directly in the TodoItem:
class TodoItem extends ConsumerWidget {
final int index;
const TodoItem({super.key, required this.index});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch only this specific task using select
final task = ref.watch(
tasksProvider.select((asyncValue) {
return asyncValue.value?[index];
}),
);
if (task == null) return const SizedBox.shrink();
// ... rest of the widget
}
}
Common Pitfalls to Avoid
1. ❌ Forgetting Immutability
// This won't work!
tasks[index] = newTask;
state = AsyncValue.data(tasks);
Always create a new list reference.
2. ❌ Not Using Select for List Length
// This rebuilds ListView on every task change!
final tasks = ref.watch(tasksProvider).value ?? [];
Use select to watch only the length:
final tasksLength = ref.watch(
tasksProvider.select((asyncValue) => asyncValue.value?.length),
);
@deprecated: Using ProviderScope for Overrides
An old pattern uses ProviderScope to override providers:
// Define a provider to be overridden
final taskIndexProvider = Provider<int>((ref) {
throw UnimplementedError();
});
// In ListView
return ProviderScope(
overrides: [taskIndexProvider.overrideWithValue(index)],
child: const TodoItem(),
);
// In TodoItem
final index = ref.watch(taskIndexProvider);
final task = ref.watch(
tasksProvider.select((asyncValue) => asyncValue.value?[index]),
);
This approach is now deprecated in favor of family providers.
Complete Code Repository
The complete working example is available in this repository with two branches, main and family. Key files:
lib/models/task.dart- Task modellib/providers/task_providers.dart- AsyncNotifier and family providerlib/screens/tasks_screen.dart- Optimized ListViewlib/widgets/todo_item.dart- Optimized list item widget
Summary: The Complete Optimization Flow
ListView watches only length → Rebuilds only when items added/removed
Only visible items are built → Thanks to ListView.builder's lazy loading
Each visible item gets a family provider → Isolated state watching
Family providers watch the main list → But extract only their index
On state update, all active family providers are notified → Only ~10-15 for visible items
All active family providers recompute their state → Extract their respective task from the new list
ref.watchperforms equality checks → Compares previous state with new state using==or identityref.watchcallsbuild()only when equality check fails → (previous != new)Only changed items rebuild → Others skip the build method entirely
Immutability ensures correct equality checks →
copyWithcreates new objects, unchanged tasks remain same objects


