Solving State Management Code Dispersion in Flutter

Solving State Management Code Dispersion in Flutter

When developing with Flutter, we often encounter state management code scattered throughout widgets. Today, let’s explore various ways to solve this problem.

Problematic Code Example

Here’s a typical example of problematic code:

class ProductListScreen extends StatefulWidget {
  const ProductListScreen({super.key});

  @override
  State<ProductListScreen> createState() => _ProductListScreenState();
}

class _ProductListScreenState extends State<ProductListScreen> {
  bool _isLoading = false;
  String _error = '';
  List<Product> _products = [];
  bool _isFavoriteOnly = false;

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

  Future<void> _fetchProducts() async {
    setState(() => _isLoading = true);
    try {
      final response = await api.getProducts();
      setState(() {
        _products = response;
        _error = '';
      });
    } catch (e) {
      setState(() => _error = e.toString());
    } finally {
      setState(() => _isLoading = false);
    }
  }

  void _toggleFavorite(String productId) async {
    try {
      await api.toggleFavorite(productId);
      _fetchProducts(); // Reload entire list
    } catch (e) {
      setState(() => _error = e.toString());
    }
  }

  void _toggleFavoriteFilter() {
    setState(() {
      _isFavoriteOnly = !_isFavoriteOnly;
      _fetchProducts();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Product List'),
        actions: [
          IconButton(
            icon: Icon(_isFavoriteOnly ? Icons.favorite : Icons.favorite_border),
            onPressed: _toggleFavoriteFilter,
          ),
        ],
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _error.isNotEmpty
              ? Center(child: Text(_error))
              : ListView.builder(
                  itemCount: _products.length,
                  itemBuilder: (context, index) {
                    final product = _products[index];
                    return ListTile(
                      title: Text(product.name),
                      trailing: IconButton(
                        icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
                        onPressed: () => _toggleFavorite(product.id),
                      ),
                    );
                  },
                ),
    );
  }
}

Problems with this code:

  • State management logic mixed with UI code
  • Difficult to reuse
  • Hard to test
  • Maintenance becomes challenging as code grows

Solution 1: Separate Controller Class

Let’s separate the state management logic into a dedicated controller class:

// product_list_controller.dart
class ProductListController {
  bool isLoading = false;
  String error = '';
  List<Product> products = [];
  bool isFavoriteOnly = false;

  final void Function(void Function()) setState;

  ProductListController(this.setState);

  Future<void> fetchProducts() async {
    setState(() => isLoading = true);
    try {
      final response = await api.getProducts();
      setState(() {
        products = response;
        error = '';
      });
    } catch (e) {
      setState(() => error = e.toString());
    } finally {
      setState(() => isLoading = false);
    }
  }

  Future<void> toggleFavorite(String productId) async {
    try {
      await api.toggleFavorite(productId);
      fetchProducts();
    } catch (e) {
      setState(() => error = e.toString());
    }
  }

  void toggleFavoriteFilter() {
    setState(() {
      isFavoriteOnly = !isFavoriteOnly;
      fetchProducts();
    });
  }
}

// product_list_screen.dart
class _ProductListScreenState extends State<ProductListScreen> {
  late final _controller = ProductListController(setState);

  @override
  void initState() {
    super.initState();
    _controller.fetchProducts();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Product List'),
        actions: [
          IconButton(
            icon: Icon(_controller.isFavoriteOnly ? Icons.favorite : Icons.favorite_border),
            onPressed: _controller.toggleFavoriteFilter,
          ),
        ],
      ),
      body: _controller.isLoading
          ? const Center(child: CircularProgressIndicator())
          : _controller.error.isNotEmpty
              ? Center(child: Text(_controller.error))
              : ListView.builder(
                  itemCount: _controller.products.length,
                  itemBuilder: (context, index) {
                    final product = _controller.products[index];
                    return ListTile(
                      title: Text(product.name),
                      trailing: IconButton(
                        icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
                        onPressed: () => _controller.toggleFavorite(product.id),
                      ),
                    );
                  },
                ),
    );
  }
}

Solution 2: Using ChangeNotifier

Using Flutter’s ChangeNotifier enables cleaner state management:

// product_list_provider.dart
class ProductListProvider extends ChangeNotifier {
  bool _isLoading = false;
  String _error = '';
  List<Product> _products = [];
  bool _isFavoriteOnly = false;

  bool get isLoading => _isLoading;
  String get error => _error;
  List<Product> get products => _products;
  bool get isFavoriteOnly => _isFavoriteOnly;

  Future<void> fetchProducts() async {
    _isLoading = true;
    notifyListeners();

    try {
      final response = await api.getProducts();
      _products = response;
      _error = '';
    } catch (e) {
      _error = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  Future<void> toggleFavorite(String productId) async {
    try {
      await api.toggleFavorite(productId);
      fetchProducts();
    } catch (e) {
      _error = e.toString();
      notifyListeners();
    }
  }

  void toggleFavoriteFilter() {
    _isFavoriteOnly = !_isFavoriteOnly;
    fetchProducts();
  }
}

// product_list_screen.dart
class ProductListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => ProductListProvider()..fetchProducts(),
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Product List'),
          actions: [
            Consumer<ProductListProvider>(
              builder: (_, provider, __) => IconButton(
                icon: Icon(provider.isFavoriteOnly ? Icons.favorite : Icons.favorite_border),
                onPressed: provider.toggleFavoriteFilter,
              ),
            ),
          ],
        ),
        body: Consumer<ProductListProvider>(
          builder: (_, provider, __) {
            if (provider.isLoading) {
              return const Center(child: CircularProgressIndicator());
            }

            if (provider.error.isNotEmpty) {
              return Center(child: Text(provider.error));
            }

            return ListView.builder(
              itemCount: provider.products.length,
              itemBuilder: (context, index) {
                final product = provider.products[index];
                return ListTile(
                  title: Text(product.name),
                  trailing: IconButton(
                    icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
                    onPressed: () => provider.toggleFavorite(product.id),
                  ),
                );
              },
            );
          },
        ),
      ),
    );
  }
}

Solution 3: Using Riverpod

Riverpod provides more powerful and flexible state management:

// product_list_provider.dart
final productListProvider = StateNotifierProvider<ProductListNotifier, ProductListState>((ref) {
  return ProductListNotifier();
});

class ProductListState {
  final bool isLoading;
  final String error;
  final List<Product> products;
  final bool isFavoriteOnly;

  const ProductListState({
    this.isLoading = false,
    this.error = '',
    this.products = const [],
    this.isFavoriteOnly = false,
  });

  ProductListState copyWith({
    bool? isLoading,
    String? error,
    List<Product>? products,
    bool? isFavoriteOnly,
  }) {
    return ProductListState(
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
      products: products ?? this.products,
      isFavoriteOnly: isFavoriteOnly ?? this.isFavoriteOnly,
    );
  }
}

class ProductListNotifier extends StateNotifier<ProductListState> {
  ProductListNotifier() : super(const ProductListState());

  Future<void> fetchProducts() async {
    state = state.copyWith(isLoading: true);

    try {
      final response = await api.getProducts();
      state = state.copyWith(
        products: response,
        error: '',
        isLoading: false,
      );
    } catch (e) {
      state = state.copyWith(
        error: e.toString(),
        isLoading: false,
      );
    }
  }

  Future<void> toggleFavorite(String productId) async {
    try {
      await api.toggleFavorite(productId);
      fetchProducts();
    } catch (e) {
      state = state.copyWith(error: e.toString());
    }
  }

  void toggleFavoriteFilter() {
    state = state.copyWith(
      isFavoriteOnly: !state.isFavoriteOnly,
    );
    fetchProducts();
  }
}

// product_list_screen.dart
class ProductListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(productListProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Product List'),
        actions: [
          IconButton(
            icon: Icon(state.isFavoriteOnly ? Icons.favorite : Icons.favorite_border),
            onPressed: () => ref.read(productListProvider.notifier).toggleFavoriteFilter(),
          ),
        ],
      ),
      body: state.isLoading
          ? const Center(child: CircularProgressIndicator())
          : state.error.isNotEmpty
              ? Center(child: Text(state.error))
              : ListView.builder(
                  itemCount: state.products.length,
                  itemBuilder: (context, index) {
                    final product = state.products[index];
                    return ListTile(
                      title: Text(product.name),
                      trailing: IconButton(
                        icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
                        onPressed: () => ref
                            .read(productListProvider.notifier)
                            .toggleFavorite(product.id),
                      ),
                    );
                  },
                ),
    );
  }
}

Pros and Cons of Each Approach

Controller Pattern

Pros:

  • Simple implementation
  • Easy transition from existing StatefulWidget
  • No additional package dependencies

Cons:

  • Inconvenient state subscription
  • Difficult global state management
  • Requires setState calls

ChangeNotifier

Pros:

  • Built-in Flutter feature
  • Easy state subscription
  • Well integrated with Provider package

Cons:

  • May cause unnecessary rebuilds
  • May not be suitable for complex state management

Riverpod

Pros:

  • Compile-time safety
  • Fine-grained state management
  • Easy to test
  • Good code completion

Cons:

  • Learning curve
  • More boilerplate code
  • Additional package dependency

Conclusion

Separating state management code significantly improves code maintainability and reusability. Choose the appropriate method based on your project’s scale and requirements:

  • Small projects: Controller pattern
  • Medium-scale: ChangeNotifier
  • Large-scale projects: Riverpod

Regardless of the chosen method, the key is separating state management logic from UI code.

References