Migrating to Hooks Riverpod: Complete Guide

This guide demonstrates how to migrate from traditional state management to hooks_riverpod in your FlipDare project.

๐Ÿš€ Why Hooks Riverpod?

Key Benefits

  1. Automatic Memory Management: No need to manually dispose controllers or streams
  2. Built-in Caching: Automatic caching with intelligent invalidation
  3. Offline Support: Built-in support for offline mode and data persistence
  4. Type Safety: Full type safety with compile-time checks
  5. Testability: Easy to test with provider overrides
  6. Performance: Optimized rebuilds only when needed
  7. Code Generation: Automatic provider generation with less boilerplate

Before vs After Comparison

Before (Traditional Approach)

class OldPledgeWidget extends StatefulWidget {
  @override
  _OldPledgeWidgetState createState() => _OldPledgeWidgetState();
}

class _OldPledgeWidgetState extends State<OldPledgeWidget> {
  PledgeService? _pledgeService;
  List<PledgeModel>? _pledges;
  bool _loading = true;
  String? _error;
  StreamSubscription? _subscription;

  @override
  void initState() {
    super.initState();
    _initializeService();
  }

  void _initializeService() async {
    try {
      _pledgeService = await PledgeService.create();
      _subscription = await _pledgeService!.getMyPledges().listen(
        (snapshot) {
          setState(() {
            _pledges = snapshot.docs.map((doc) =>
              PledgeModel.fromJson(doc.data(), id: doc.id)
            ).toList();
            _loading = false;
          });
        },
        onError: (error) {
          setState(() {
            _error = error.toString();
            _loading = false;
          });
        },
      );
    } catch (e) {
      setState(() {
        _error = e.toString();
        _loading = false;
      });
    }
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) return CircularProgressIndicator();
    if (_error != null) return Text('Error: $_error');
    return ListView.builder(/* ... */);
  }
}

After (Hooks Riverpod)

class NewPledgeWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final pledgesAsync = ref.watch(myPledgesStreamProvider);

    return pledgesAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
      data: (pledges) => ListView.builder(/* ... */),
    );
  }
}

๐Ÿ“ Project Structure

lib/
โ”œโ”€โ”€ main_riverpod_example.dart          # Entry point
โ”œโ”€โ”€ providers/
โ”‚   โ”œโ”€โ”€ pledge_providers.dart           # Riverpod providers (with codegen)
โ”‚   โ””โ”€โ”€ pledge_providers.g.dart         # Generated providers
โ””โ”€โ”€ pages/
    โ””โ”€โ”€ firebase/
        โ”œโ”€โ”€ find_pledges.dart           # Full-featured example
        โ””โ”€โ”€ find_pledges_simple.dart    # Simple example

๐Ÿ”ง Setup Instructions

1. Add Dependencies

Add to your pubspec.yaml:

dependencies:
  flutter_hooks: ^0.21.0
  hooks_riverpod: ^2.6.1
  riverpod_annotation: ^2.6.1

dev_dependencies:
  riverpod_generator: ^2.6.3
  riverpod_lint: ^2.6.3
  build_runner: ^2.5.4

2. Run Code Generation

dart run build_runner build

3. Wrap Your App

void main() {
  runApp(
    ProviderScope(  // Required for Riverpod
      child: MyApp(),
    ),
  );
}

๐Ÿ—๏ธ Provider Patterns

1. Simple State Provider

final searchQueryProvider = StateProvider<String>((ref) => '');

// Usage
final searchQuery = ref.watch(searchQueryProvider);
ref.read(searchQueryProvider.notifier).state = 'new value';

2. Async Data Provider

@riverpod
Future<List<PledgeModel>> pledges(PledgesRef ref) async {
  final service = ref.watch(pledgeServiceProvider);
  return await service.getAllPledges();
}

// Usage
final pledgesAsync = ref.watch(pledgesProvider);

3. Stream Provider

@riverpod
Stream<List<PledgeModel>> pledgesStream(PledgesStreamRef ref) async* {
  final service = ref.watch(pledgeServiceProvider);
  final stream = await service.getMyPledgesStream();

  await for (final snapshot in stream) {
    yield snapshot.docs.map((doc) =>
      PledgeModel.fromJson(doc.data(), id: doc.id)
    ).toList();
  }
}

4. StateNotifier for Complex State

@riverpod
class PledgeManager extends _$PledgeManager {
  @override
  AsyncValue<List<PledgeModel>> build() {
    return const AsyncValue.loading();
  }

  Future<void> loadPledges() async {
    state = const AsyncValue.loading();
    try {
      final pledges = await _fetchPledges();
      state = AsyncValue.data(pledges);
    } catch (error, stackTrace) {
      state = AsyncValue.error(error, stackTrace);
    }
  }

  Future<void> addPledge(PledgeModel pledge) async {
    // Optimistic update
    state.whenData((pledges) {
      state = AsyncValue.data([...pledges, pledge]);
    });

    try {
      await _savePledge(pledge);
    } catch (error) {
      // Revert on error
      await loadPledges();
      rethrow;
    }
  }
}

๐ŸŽฃ Hook Patterns

1. Local State Management

class MyWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Local state with hooks
    final searchQuery = useState<String>('');
    final selectedFilter = useState<PledgeStatus?>(null);

    // Controllers
    final searchController = useTextEditingController();
    final scrollController = useScrollController();

    // Effects
    useEffect(() {
      void onSearchChanged() {
        searchQuery.value = searchController.text;
      }

      searchController.addListener(onSearchChanged);
      return () => searchController.removeListener(onSearchChanged);
    }, [searchController]);

    return /* ... */;
  }
}

2. Animation with Hooks

final animationController = useAnimationController(
  duration: Duration(milliseconds: 300),
);

final slideAnimation = useAnimation(
  Tween<Offset>(
    begin: Offset(0, 1),
    end: Offset.zero,
  ).animate(CurvedAnimation(
    parent: animationController,
    curve: Curves.easeInOut,
  )),
);

3. Memoization

final expensiveValue = useMemoized(() {
  return performExpensiveComputation(data);
}, [data]);

๐Ÿ”„ Data Flow Patterns

1. Filtered Data Provider

@riverpod
List<PledgeModel> filteredPledges(FilteredPledgesRef ref) {
  final pledges = ref.watch(pledgesProvider).value ?? [];
  final searchQuery = ref.watch(searchQueryProvider);
  final statusFilter = ref.watch(pledgeStatusFilterProvider);

  return pledges.where((pledge) {
    final matchesSearch = searchQuery.isEmpty ||
        pledge.dareId.contains(searchQuery);
    final matchesStatus = statusFilter == null ||
        pledge.status == statusFilter;

    return matchesSearch && matchesStatus;
  }).toList();
}

2. Dependency Injection

@riverpod
PledgeService pledgeService(PledgeServiceRef ref) {
  final appDb = ref.watch(appDbProvider);
  final cache = ref.watch(cacheProvider);

  return PledgeService(appDb: appDb, cache: cache);
}

3. Provider Composition

@riverpod
Future<DashboardData> dashboardData(DashboardDataRef ref) async {
  final pledges = await ref.watch(pledgesProvider.future);
  final user = await ref.watch(currentUserProvider.future);
  final stats = await ref.watch(statsProvider.future);

  return DashboardData(
    pledges: pledges,
    user: user,
    stats: stats,
  );
}

๐ŸŽฏ Advanced Patterns

1. Pagination

@riverpod
class PaginatedPledges extends _$PaginatedPledges {
  @override
  Future<PaginatedData<PledgeModel>> build() async {
    return _loadPage(1);
  }

  Future<void> loadNextPage() async {
    final current = state.value;
    if (current?.hasNextPage != true) return;

    state = AsyncValue.loading();
    try {
      final nextPage = await _loadPage(current!.currentPage + 1);
      state = AsyncValue.data(
        current.copyWith(
          items: [...current.items, ...nextPage.items],
          currentPage: nextPage.currentPage,
          hasNextPage: nextPage.hasNextPage,
        ),
      );
    } catch (error, stackTrace) {
      state = AsyncValue.error(error, stackTrace);
    }
  }
}

2. Optimistic Updates

Future<void> updatePledgeStatus(PledgeModel pledge, PledgeStatus newStatus) async {
  // Optimistic update
  ref.read(pledgesProvider.notifier).updateLocal(
    pledge.copyWith(status: newStatus),
  );

  try {
    await pledgeService.updatePledge(pledge.id!, newStatus);
  } catch (error) {
    // Revert on error
    ref.invalidate(pledgesProvider);
    rethrow;
  }
}

3. Cache Invalidation

Future<void> refreshData() async {
  // Invalidate specific providers
  ref.invalidate(pledgesProvider);
  ref.invalidate(userStatsProvider);

  // Or invalidate by family parameter
  ref.invalidate(pledgesForDareProvider('dare123'));
}

๐Ÿงช Testing

1. Provider Testing

void main() {
  testWidgets('pledges display correctly', (tester) async {
    await tester.pumpWidget(
      ProviderScope(
        overrides: [
          pledgesProvider.overrideWith((ref) async => mockPledges),
        ],
        child: MyApp(),
      ),
    );

    expect(find.text('Test Pledge'), findsOneWidget);
  });
}

2. Integration Testing

void main() {
  testWidgets('full pledge flow', (tester) async {
    final container = ProviderContainer();

    // Test provider directly
    final pledges = await container.read(pledgesProvider.future);
    expect(pledges.length, 2);

    container.dispose();
  });
}

๐Ÿ” Debugging

1. Provider Observer

class MyProviderObserver extends ProviderObserver {
  @override
  void didUpdateProvider(
    ProviderBase provider,
    Object? previousValue,
    Object? newValue,
    ProviderContainer container,
  ) {
    print('Provider ${provider.name} updated: $newValue');
  }
}

void main() {
  runApp(
    ProviderScope(
      observers: [MyProviderObserver()],
      child: MyApp(),
    ),
  );
}

2. State Inspection

// In development mode
final pledgesState = ref.read(pledgesProvider);
print('Current pledges state: $pledgesState');

๐Ÿš€ Performance Tips

  1. Use .select() for partial updates:

    final pledgeCount = ref.watch(pledgesProvider.select((value) =>
      value.whenData((pledges) => pledges.length)
    ));
    
  2. Dispose providers when not needed:

    ref.invalidate(expensiveProvider);
    
  3. Use autoDispose for temporary data:

    @riverpod
    Future<Data> temporaryData(TemporaryDataRef ref) async {
      ref.keepAlive(); // Prevents auto-disposal if needed
      return fetchData();
    }
    

๐Ÿ“ Migration Checklist

  • Add Riverpod dependencies
  • Wrap app with ProviderScope
  • Convert StatefulWidgets to HookConsumerWidget
  • Create providers for services and state
  • Replace manual state management with hooks
  • Add error handling with AsyncValue
  • Implement optimistic updates
  • Add tests for providers
  • Set up code generation
  • Configure provider observers for debugging

๐Ÿ”— Key Files

  • find_pledges_simple.dart - Simple example with basic providers
  • find_pledges.dart - Complete example with advanced patterns
  • pledge_providers.dart - Provider definitions with code generation
  • main_riverpod_example.dart - App setup and navigation

This migration provides you with a robust, scalable, and maintainable state management solution that handles offline mode, caching, and real-time updates automatically.