Published on

Reactive Programming in Flutter: Practical Use Cases

Authors
  • avatar
    Name
    Phat Tran
    Twitter

Reactive programming in Flutter simply means the UI updates automatically when the underlying data changes. Instead of manually telling the UI to refresh, you establish a connection between data and the view.

Let's skip the theory and look directly at the most common reactive tools in Flutter, what they are used for, and how to implement them.


1. Dart Streams & StreamBuilder

Best used for: Continuous asynchronous data. Think of WebSockets, Geolocation tracking, or Firebase Realtime Database.

If data arrives over time, use a Stream.

Example: Listening to a WebSocket

import 'package:flutter/material.dart';

class PriceTracker extends StatelessWidget {
  // A stream that emits new price data every second
  final Stream<double> priceStream = Stream.periodic(
    const Duration(seconds: 1),
    (count) => 100.0 + (count * 0.5),
  );

  
  Widget build(BuildContext context) {
    return StreamBuilder<double>(
      stream: priceStream,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        }

        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        }

        return Text('Bitcoin Price: $${snapshot.data}');
      },
    );
  }
}

2. ValueNotifier & ValueListenableBuilder

Best used for: Synchronous, single-value local state. Perfect for form validation, toggling themes, or tracking simple animations.

It is incredibly lightweight and doesn't require third-party packages.

Example: Password Visibility Toggle

import 'package:flutter/material.dart';

class PasswordField extends StatefulWidget {
  
  _PasswordFieldState createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<PasswordField> {
  // Simple reactive state
  final ValueNotifier<bool> _obscureText = ValueNotifier<bool>(true);

  
  void dispose() {
    _obscureText.dispose(); // Always dispose!
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return ValueListenableBuilder<bool>(
      valueListenable: _obscureText,
      builder: (context, isObscured, child) {
        return TextField(
          obscureText: isObscured,
          decoration: InputDecoration(
            labelText: 'Password',
            suffixIcon: IconButton(
              icon: Icon(isObscured ? Icons.visibility_off : Icons.visibility),
              onPressed: () => _obscureText.value = !_obscureText.value, // Triggers rebuild
            ),
          ),
        );
      },
    );
  }
}

3. RxDart

Best used for: Complex data transformations. When you need to combine multiple streams, throttle user inputs, or debounce search queries.

RxDart extends Dart's native Streams with powerful operators.

Example: Search Bar with Debouncing

When a user types in a search bar, you don't want to hit the API on every keystroke. RxDart's debounceTime fixes this.

import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';

class SearchScreen extends StatefulWidget {
  
  _SearchScreenState createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  final _searchController = BehaviorSubject<String>();

  
  void initState() {
    super.initState();

    // Only trigger search when the user stops typing for 500ms
    _searchController.stream
        .debounceTime(const Duration(milliseconds: 500))
        .distinctUntilChanged()
        .listen((query) {
      if (query.isNotEmpty) {
        print('Fetching API for: $query');
        // Call your API here
      }
    });
  }

  
  void dispose() {
    _searchController.close();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return TextField(
      onChanged: _searchController.add, // Feed the stream
      decoration: const InputDecoration(
        hintText: 'Search products...',
        prefixIcon: Icon(Icons.search),
      ),
    );
  }
}

4. Riverpod (Global State Management)

Best used for: App-wide global state, caching API responses, and dependency injection.

When ValueNotifier or ChangeNotifier becomes too messy to pass down the widget tree, Riverpod is the modern standard to manage state cleanly.

Example: Caching an API Request

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 1. Define the provider
final userProvider = FutureProvider.autoDispose<String>((ref) async {
  // Simulate an API call
  await Future.delayed(const Duration(seconds: 2));
  return "John Doe";
});

// 2. Consume the provider
class UserProfile extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsyncValue = ref.watch(userProvider);

    return Scaffold(
      body: Center(
        // AsyncValue automatically handles loading, error, and data states
        child: userAsyncValue.when(
          loading: () => const CircularProgressIndicator(),
          error: (error, stack) => Text('Failed to load user: $error'),
          data: (userName) => Text('Welcome back, $userName!'),
        ),
      ),
    );
  }
}

Summary: When to use what?

ToolBest Use CaseExample
StreamsContinuous async events over timeFirebase Realtime data, Sockets
ValueNotifierLightweight, synchronous local UI stateForm validation, Toggles
RxDartManipulating and combining streamsSearch debouncing, Stream merging
RiverpodGlobal app state and API cachingUser Authentication, Cart State

By matching the right reactive tool to the specific problem, your codebase will remain clean, testable, and highly performant.