Manual Suspense

A programmatically controlled suspense widget inspired by React Suspense. Manage loading states declaratively with fine-grained manual control over task completion.

pub package License: MIT

Features

  • ๐ŸŽฏ Manual Control: Programmatically start and complete suspense tasks
  • ๐Ÿ”„ Declarative UI: Automatically switches between loading and content states
  • ๐Ÿงน Auto Cleanup: Optional automatic resource cleanup on widget disposal
  • ๐Ÿ”‘ Key-based System: Manage multiple independent loading states
  • ๐Ÿ“ฆ Zero Dependencies: Built entirely with Flutter framework
  • โšก Lightweight: Minimal overhead, maximum performance

Installation

Add this to your pubspec.yaml:

dependencies:
  manual_suspense: ^1.0.0

Then run:

flutter pub get

Quick Start

import 'package:manual_suspense/manual_suspense.dart';

// 1. Display the widget
ManualSuspense(
  suspenseKey: 'fetch-users',
  fallback: CircularProgressIndicator(),
  child: UsersList(),
)

// 2. Control it programmatically
void loadUsers() async {
  SuspenseController.start('fetch-users');
  
  try {
    final users = await api.fetchUsers();
    setState(() => _users = users);
    SuspenseController.complete('fetch-users');
  } catch (e) {
    // Handle error
    SuspenseController.complete('fetch-users');
  }
}

Usage Examples

Basic Usage

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ManualSuspense(
        suspenseKey: 'load-data',
        fallback: Center(
          child: CircularProgressIndicator(),
        ),
        child: DataView(),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _refreshData,
        child: Icon(Icons.refresh),
      ),
    );
  }

  Future<void> _refreshData() async {
    SuspenseController.start('load-data');
    await Future.delayed(Duration(seconds: 2));
    SuspenseController.complete('load-data');
  }
}

With Auto Cleanup

ManualSuspense(
  suspenseKey: 'profile-${userId}',
  autoRemoveOnDispose: true, // Cleanup when widget is removed
  fallback: ProfileSkeleton(),
  child: ProfileCard(user: user),
)

Multiple Independent Tasks

Column(
  children: [
    ManualSuspense(
      suspenseKey: 'users',
      fallback: Text('Loading users...'),
      child: UsersList(),
    ),
    ManualSuspense(
      suspenseKey: 'posts',
      fallback: Text('Loading posts...'),
      child: PostsList(),
    ),
  ],
)

Pull to Refresh

class DataPage extends StatefulWidget {
  @override
  State<DataPage> createState() => _DataPageState();
}

class _DataPageState extends State<DataPage> {
  List<String> _items = [];

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: _handleRefresh,
      child: ManualSuspense(
        suspenseKey: 'data-list',
        fallback: Center(child: CircularProgressIndicator()),
        child: ListView.builder(
          itemCount: _items.length,
          itemBuilder: (context, index) => ListTile(
            title: Text(_items[index]),
          ),
        ),
      ),
    );
  }

  Future<void> _handleRefresh() async {
    SuspenseController.start('data-list');
    
    try {
      final data = await fetchData();
      setState(() => _items = data);
      SuspenseController.complete('data-list');
    } catch (e) {
      SuspenseController.complete('data-list');
      // Show error
    }
  }
}

Form Submission

class LoginForm extends StatefulWidget {
  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(/* ... */),
          TextFormField(/* ... */),
          ManualSuspense(
            suspenseKey: 'login-submit',
            fallback: CircularProgressIndicator(),
            child: ElevatedButton(
              onPressed: _handleSubmit,
              child: Text('Login'),
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _handleSubmit() async {
    if (!_formKey.currentState!.validate()) return;
    
    SuspenseController.start('login-submit');
    
    try {
      await authService.login(email, password);
      SuspenseController.complete('login-submit');
      // Navigate to home
    } catch (e) {
      SuspenseController.complete('login-submit');
      // Show error
    }
  }
}

Error Handling Pattern

Future<void> loadData() async {
  SuspenseController.start('my-data');
  
  try {
    final data = await api.fetchData();
    setState(() => _data = data);
    SuspenseController.complete('my-data');
  } on NetworkException catch (e) {
    SuspenseController.complete('my-data');
    showErrorDialog('Network error: ${e.message}');
  } catch (e) {
    SuspenseController.complete('my-data');
    showErrorDialog('Unexpected error occurred');
  }
}

API Reference

SuspenseController

Static methods for controlling suspense tasks:

Method Description
start(String key) Start a new suspense task
complete(String key) Complete a suspense task
remove(String key) Remove task and cleanup resources
has(String key) Check if task exists
isRunning(String key) Check if task is currently running
clearAll() Remove all tasks and cleanup

ManualSuspense

Widget properties:

Property Type Required Default Description
suspenseKey String โœ… - Unique identifier for the task
fallback Widget โœ… - Widget to show while loading
child Widget โœ… - Widget to show when completed
autoRemoveOnDispose bool โŒ false Auto cleanup on disposal

When to Use

โœ… Perfect for:

  • Manual data fetching with custom loading states
  • Multi-step async workflows
  • Form submissions with loading indicators
  • Pull-to-refresh implementations
  • Complex async operations requiring fine-grained control
  • Custom loading sequences
  • Retry mechanisms

โŒ Not recommended for:

  • Simple FutureBuilder use cases
  • Automatic API call management (consider state management solutions like Riverpod, Bloc)
  • When you don't need manual control over loading states

Comparison with Alternatives

Feature manual_suspense FutureBuilder State Management
Manual control โœ… โŒ โœ…
Declarative UI โœ… โœ… โœ…
Multiple tasks โœ… โŒ โœ…
Zero dependencies โœ… โœ… โŒ
Learning curve Low Low Medium-High

Contributing

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Support

If you like this package, please give it a โญ๏ธ on GitHub!

For issues and feature requests, please visit the issue tracker.

Libraries

manual_suspense
A Flutter package that provides programmatically controlled suspense widgets inspired by React Suspense, allowing developers to manage loading states declaratively with manual control over task completion.