Back to Posts

Advanced State Management with Riverpod

8 min read

Riverpod is a powerful state management solution for Flutter that provides compile-time safety, testability, and flexibility. This comprehensive guide will walk you through advanced techniques for managing state in Flutter applications using Riverpod.

Why Choose Riverpod?

Riverpod offers several advantages over other state management solutions:

  1. Compile-time Safety: Catch errors before runtime
  2. Testability: Easy to test and mock
  3. Flexibility: Works with any architecture
  4. Performance: Efficient state updates
  5. Scalability: Handles complex state management

Basic Providers

1. Simple Provider

final counterProvider = Provider<int>((ref) => 0);

class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    
    return Text('Count: $count');
  }
}

2. State Provider

final counterStateProvider = StateProvider<int>((ref) => 0);

class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterStateProvider);
    
    return ElevatedButton(
      onPressed: () => ref.read(counterStateProvider.notifier).state++,
      child: Text('Count: $count'),
    );
  }
}

Advanced Providers

1. State Notifier Provider

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);

  void increment() => state++;
  void decrement() => state--;
  void reset() => state = 0;
}

final counterNotifierProvider = StateNotifierProvider<CounterNotifier, int>(
  (ref) => CounterNotifier(),
);

class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterNotifierProvider);
    final notifier = ref.read(counterNotifierProvider.notifier);
    
    return Column(
      children: [
        Text('Count: $counter'),
        ElevatedButton(
          onPressed: notifier.increment,
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

2. Future Provider

final userDataProvider = FutureProvider<UserData>((ref) async {
  final repository = ref.watch(userRepositoryProvider);
  return repository.fetchUserData();
});

class UserWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userDataProvider);
    
    return userAsync.when(
      data: (user) => Text('Welcome, ${user.name}'),
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
    );
  }
}

3. Stream Provider

final messagesProvider = StreamProvider<List<Message>>((ref) {
  final repository = ref.watch(messageRepositoryProvider);
  return repository.watchMessages();
});

class MessagesWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final messagesAsync = ref.watch(messagesProvider);
    
    return messagesAsync.when(
      data: (messages) => ListView.builder(
        itemCount: messages.length,
        itemBuilder: (context, index) => MessageTile(messages[index]),
      ),
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
    );
  }
}

Dependency Injection

1. Repository Pattern

abstract class UserRepository {
  Future<UserData> fetchUserData();
  Future<void> updateUserData(UserData data);
}

class UserRepositoryImpl implements UserRepository {
  final ApiClient _client;
  
  UserRepositoryImpl(this._client);
  
  @override
  Future<UserData> fetchUserData() async {
    // Implementation
  }
  
  @override
  Future<void> updateUserData(UserData data) async {
    // Implementation
  }
}

final userRepositoryProvider = Provider<UserRepository>((ref) {
  final client = ref.watch(apiClientProvider);
  return UserRepositoryImpl(client);
});

2. Service Locator

final serviceLocatorProvider = Provider<ServiceLocator>((ref) {
  return ServiceLocator(
    userRepository: ref.watch(userRepositoryProvider),
    authService: ref.watch(authServiceProvider),
    // Add more services
  );
});

class ServiceLocator {
  final UserRepository userRepository;
  final AuthService authService;
  
  ServiceLocator({
    required this.userRepository,
    required this.authService,
  });
}

State Persistence

1. Shared Preferences

final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
  throw UnimplementedError();
});

final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) {
  final prefs = ref.watch(sharedPreferencesProvider);
  return ThemeModeNotifier(prefs);
});

class ThemeModeNotifier extends StateNotifier<ThemeMode> {
  final SharedPreferences _prefs;
  
  ThemeModeNotifier(this._prefs) : super(_loadThemeMode());
  
  static ThemeMode _loadThemeMode() {
    // Load from preferences
  }
  
  void toggleTheme() {
    state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
    _saveThemeMode();
  }
  
  void _saveThemeMode() {
    // Save to preferences
  }
}

2. Hive Storage

final hiveBoxProvider = FutureProvider<Box>((ref) async {
  final box = await Hive.openBox('settings');
  return box;
});

final settingsProvider = StateNotifierProvider<SettingsNotifier, Settings>((ref) {
  final box = ref.watch(hiveBoxProvider).value;
  return SettingsNotifier(box);
});

Testing with Riverpod

1. Provider Testing

void main() {
  group('CounterNotifier Tests', () {
    late ProviderContainer container;
    
    setUp(() {
      container = ProviderContainer();
    });
    
    test('initial state is 0', () {
      expect(
        container.read(counterNotifierProvider),
        0,
      );
    });
    
    test('increment increases count', () {
      container.read(counterNotifierProvider.notifier).increment();
      expect(
        container.read(counterNotifierProvider),
        1,
      );
    });
  });
}

2. Widget Testing

void main() {
  group('CounterWidget Tests', () {
    testWidgets('displays count and increments', (tester) async {
      await tester.pumpWidget(
        ProviderScope(
          child: MaterialApp(
            home: CounterWidget(),
          ),
        ),
      );
      
      expect(find.text('Count: 0'), findsOneWidget);
      
      await tester.tap(find.byType(ElevatedButton));
      await tester.pump();
      
      expect(find.text('Count: 1'), findsOneWidget);
    });
  });
}

Advanced Patterns

1. Family Provider

final userProvider = FutureProvider.family<UserData, String>((ref, userId) async {
  final repository = ref.watch(userRepositoryProvider);
  return repository.fetchUser(userId);
});

class UserProfile extends ConsumerWidget {
  final String userId;
  
  const UserProfile({super.key, required this.userId});
  
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider(userId));
    
    return userAsync.when(
      data: (user) => UserProfileView(user),
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
    );
  }
}

2. Auto Dispose

final timerProvider = StreamProvider.autoDispose((ref) {
  final timer = Stream.periodic(
    const Duration(seconds: 1),
    (count) => count,
  );
  
  ref.onDispose(() {
    // Cleanup
  });
  
  return timer;
});

3. Scoped Providers

final authProvider = Provider<AuthService>((ref) {
  return AuthService();
});

final userProvider = Provider<UserData>((ref) {
  final auth = ref.watch(authProvider);
  return auth.currentUser;
});

final userPostsProvider = FutureProvider<List<Post>>((ref) async {
  final user = ref.watch(userProvider);
  final repository = ref.watch(postRepositoryProvider);
  return repository.fetchUserPosts(user.id);
});

Best Practices

  1. Provider Organization

    • Group related providers
    • Use meaningful names
    • Document provider purposes
    • Follow consistent patterns
  2. State Management

    • Keep state minimal
    • Use immutable state
    • Implement proper error handling
    • Handle loading states
  3. Performance

    • Use select for partial rebuilds
    • Implement proper caching
    • Avoid unnecessary rebuilds
    • Use autoDispose when appropriate
  4. Testing

    • Write unit tests for providers
    • Test widget integration
    • Mock dependencies
    • Test error cases

Common Pitfalls

  1. Unnecessary Rebuilds

    // Bad
    final userProvider = Provider<UserData>((ref) {
      return ref.watch(authProvider).currentUser;
    });
    
    // Good
    final userProvider = Provider<UserData>((ref) {
      return ref.watch(authProvider.select((auth) => auth.currentUser));
    });
  2. Improper Error Handling

    // Bad
    final dataProvider = FutureProvider((ref) async {
      return fetchData();
    });
    
    // Good
    final dataProvider = FutureProvider((ref) async {
      try {
        return await fetchData();
      } catch (e, stack) {
        ref.read(errorLoggerProvider).logError(e, stack);
        rethrow;
      }
    });
  3. Memory Leaks

    // Bad
    final streamProvider = StreamProvider((ref) {
      return Stream.periodic(Duration(seconds: 1));
    });
    
    // Good
    final streamProvider = StreamProvider.autoDispose((ref) {
      return Stream.periodic(Duration(seconds: 1));
    });

Conclusion

Advanced state management with Riverpod requires understanding of:

  1. Provider Types: Different provider types and their use cases
  2. Dependency Injection: Proper setup and usage
  3. State Persistence: Handling persistent state
  4. Testing: Comprehensive testing strategies
  5. Performance: Optimization techniques

Remember to:

  • Follow best practices
  • Write comprehensive tests
  • Handle errors properly
  • Optimize performance
  • Document your code

By following these guidelines, you can create robust and maintainable Flutter applications with Riverpod.