Skip to main content

Command Palette

Search for a command to run...

Advanced ListView Optimization in Flutter with Riverpod: Avoiding Unnecessary Rebuilds

Updated
11 min read

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:

  1. AsyncNotifierProvider - For managing mutable async state

  2. Family Providers - For creating provider instances per list item

  3. 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:

  1. it is built the first time (at this time, the AsyncNotifierProvider is fetching data the first time and so asyncValue.value is null at this time)

  2. the list of tasks is successfully fetched (asyncValue.value?.length returns an int)

  3. 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 null during initial loading (no previous data)

  • Returns previous length during refresh (cached data available)

  • Returns null on 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:

  1. Only receives index as a parameter

  2. Watches taskByIndexProvider(index) - its own dedicated provider

  3. Only 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 tasksProvider

  • Extracts 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:

  1. 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 false if completed changed

    • Triggers rebuild

  2. Without overridden == operator (default Dart behavior):

    • Uses identity comparison (identical(this, other))

    • Since Task fields are final, the class is immutable

    • When we do task.copyWith(completed: true), a new Task object is created

    • The new object has a different memory address

    • identical(oldTask, newTask) returns false

    • Triggers 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:

  1. toggleTaskCompletion creates a new list with updated task at index 2

  2. tasksProvider notifies all active family provider listeners (only those for visible items + cacheExtent)

  3. All active family providers recompute their state:

    • taskByIndexProvider(0) recomputes → returns tasks[0]

    • taskByIndexProvider(1) recomputes → returns tasks[1]

    • taskByIndexProvider(2) recomputes → returns tasks[2]

  4. ref.watch performs equality checks:

    • In TodoItem(index: 0): ref.watch checks tasks[0] == oldTasks[0]?

      • Same Task object (immutable, same memory address)

      • OR same field values (if == overridden)

      • Result: trueref.watch does NOT call build()

    • In TodoItem(index: 1): ref.watch checks tasks[1] == oldTasks[1]?

      • Same Task object

      • Result: trueref.watch does NOT call build()

    • In TodoItem(index: 2): ref.watch checks tasks[2] == oldTasks[2]?

      • Different Task object (created by copyWith)

      • OR different field values (completed changed)

      • Result: falseref.watch CALLS build()

  5. 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 tasksProvider changes

  • But ref.watch only calls build() 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 model

  • lib/providers/task_providers.dart - AsyncNotifier and family provider

  • lib/screens/tasks_screen.dart - Optimized ListView

  • lib/widgets/todo_item.dart - Optimized list item widget

Summary: The Complete Optimization Flow

  1. ListView watches only length → Rebuilds only when items added/removed

  2. Only visible items are built → Thanks to ListView.builder's lazy loading

  3. Each visible item gets a family provider → Isolated state watching

  4. Family providers watch the main list → But extract only their index

  5. On state update, all active family providers are notified → Only ~10-15 for visible items

  6. All active family providers recompute their state → Extract their respective task from the new list

  7. ref.watch performs equality checks → Compares previous state with new state using == or identity

  8. ref.watch calls build() only when equality check fails → (previous != new)

  9. Only changed items rebuild → Others skip the build method entirely

  10. Immutability ensures correct equality checkscopyWith creates new objects, unchanged tasks remain same objects