Skip to content

Restoration

Understand how to preserve and restore state in Flutter applications.


What is it?

Restoration is a mechanism in Flutter that allows you to save and restore application state across app restarts, process kills, or device reboots. It enables users to pick up exactly where they left off, preserving navigation stack, form data, scroll positions, and other user interactions. This is essential for creating professional, user-friendly applications.


Why does it exist?

Restoration exists to:

  • Preserve user state across app restarts
  • Provide seamless user experience
  • Handle system-initiated app kills
  • Support Android's "Don't keep activities"
  • Enable state persistence in iOS
  • Improve user retention and satisfaction
  • Allow users to continue from where they left off

Restoration Basics

Understanding state restoration in Flutter.

// Import required packages
import 'package:flutter/material.dart';

/// Main application entry point with restoration support
void main() {
  runApp(const MyApp());
}

/// Root application widget that enables restoration
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Restoration Demo',
      theme: ThemeData(primarySwatch: Colors.blue),

      // Enable restoration by providing a RestorationScope
      // This allows child widgets to participate in state restoration
      restorationScopeId: 'app',

      // Set the home widget that will be restored
      home: const HomeScreen(),
    );
  }
}

/// Home screen with restoration support
class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

/// State with restoration capability
class _HomeScreenState extends State<HomeScreen> with RestorationMixin {
  // 1. Provide a restoration ID for this widget
  // This ID is used to identify saved state for this widget
  @override
  String get restorationId => 'home_screen';

  // 2. Create Restorable values
  // These values will be automatically saved and restored

  // Restorable integer for counter
  // The initial value is 0, which is restored if no saved state exists
  final RestorableInt _counter = RestorableInt(0);

  // Restorable string for text input
  final RestorableString _text = RestorableString('');

  // Restorable boolean for toggle
  final RestorableBool _isEnabled = RestorableBool(false);

  // 3. Register restorable properties
  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    // Register each restorable property with a unique key
    // The key identifies the property in the saved state
    registerForRestoration(_counter, 'counter');
    registerForRestoration(_text, 'text');
    registerForRestoration(_isEnabled, 'is_enabled');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Restoration Demo'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            // Counter display and controls
            // Shows the restored counter value
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  children: [
                    // Display current counter value
                    // This value is restored when the app restarts
                    Text(
                      'Counter: ${_counter.value}',
                      style: const TextStyle(fontSize: 24),
                    ),
                    const SizedBox(height: 8),

                    // Increment and decrement buttons
                    // Changes to _counter are automatically saved
                    Row(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        // Decrement button
                        ElevatedButton(
                          onPressed: () {
                            // Update the counter value
                            // This change is automatically saved
                            _counter.value--;
                          },
                          child: const Text('-'),
                        ),
                        const SizedBox(width: 8),

                        // Increment button
                        ElevatedButton(
                          onPressed: () {
                            // Update the counter value
                            // This change is automatically saved
                            _counter.value++;
                          },
                          child: const Text('+'),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
            ),

            const SizedBox(height: 16),

            // Text input restoration
            // User input is preserved across app restarts
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  children: [
                    // Display current text value
                    // This is restored when the app restarts
                    Text(
                      'Text: ${_text.value.isEmpty ? 'Empty' : _text.value}',
                    ),
                    const SizedBox(height: 8),

                    // Text field with restoration
                    TextField(
                      // The controller is not restored directly
                      // Instead, we use _text to store the value
                      onChanged: (value) {
                        // Update the restorable string
                        // This change is automatically saved
                        _text.value = value;
                      },
                      decoration: const InputDecoration(
                        border: OutlineInputBorder(),
                        hintText: 'Enter some text...',
                      ),
                    ),
                  ],
                ),
              ),
            ),

            const SizedBox(height: 16),

            // Toggle switch restoration
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: SwitchListTile(
                  title: const Text('Enabled'),
                  subtitle: Text(
                    _isEnabled.value ? 'ON' : 'OFF',
                  ),
                  // The switch value is restored automatically
                  value: _isEnabled.value,
                  onChanged: (value) {
                    // Update the restorable boolean
                    // This change is automatically saved
                    _isEnabled.value = value;
                  },
                ),
              ),
            ),

            const Spacer(),

            // Info about restoration
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.blue[50],
                borderRadius: BorderRadius.circular(8),
              ),
              child: const Text(
                'Try restarting the app - your state will be preserved!',
                textAlign: TextAlign.center,
                style: TextStyle(fontSize: 14),
              ),
            ),
          ],
        ),
      ),
    );
  }

  // 4. Optional: Clean up restorable objects
  // Called when the widget is disposed
  @override
  void dispose() {
    // Dispose of restorable objects to prevent memory leaks
    _counter.dispose();
    _text.dispose();
    _isEnabled.dispose();
    super.dispose();
  }
}

What's happening here? - RestorationMixin enables state restoration - RestorableInt, RestorableString, RestorableBool store values - registerForRestoration connects values to restoration IDs - dispose() prevents memory leaks - State is automatically saved and restored


Restoration with Navigation

Restoring navigation stack and routes.

/// Detail screen with restoration support
class DetailScreen extends StatefulWidget {
  const DetailScreen({super.key});

  @override
  State<DetailScreen> createState() => _DetailScreenState();
}

/// State with restoration support for navigation
class _DetailScreenState extends State<DetailScreen> with RestorationMixin {
  // Restoration ID for this screen
  @override
  String get restorationId => 'detail_screen';

  // Restorable string for the item title
  final RestorableString _itemTitle = RestorableString('Item');

  // Restorable integer for the item index
  final RestorableInt _itemIndex = RestorableInt(0);

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    // Register both restorable values
    registerForRestoration(_itemTitle, 'item_title');
    registerForRestoration(_itemIndex, 'item_index');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Detail Screen'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // Display the restored item info
            Text(
              'Item: ${_itemTitle.value}',
              style: const TextStyle(fontSize: 24),
            ),
            Text(
              'Index: ${_itemIndex.value}',
              style: const TextStyle(fontSize: 18),
            ),
            const SizedBox(height: 16),

            // Buttons to modify the item
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  // Change the item title
                  onPressed: () {
                    _itemTitle.value = 'Item ${_itemIndex.value + 1}';
                  },
                  child: const Text('Change Title'),
                ),
                const SizedBox(width: 8),
                ElevatedButton(
                  // Increment the item index
                  onPressed: () {
                    _itemIndex.value++;
                  },
                  child: const Text('Next Item'),
                ),
              ],
            ),
            const SizedBox(height: 16),

            // Back button
            ElevatedButton(
              onPressed: () {
                // Pop the screen
                Navigator.pop(context);
              },
              child: const Text('Go Back'),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _itemTitle.dispose();
    _itemIndex.dispose();
    super.dispose();
  }
}

/// Main app with restoration-enabled navigation
class NavigationRestorationApp extends StatelessWidget {
  const NavigationRestorationApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Navigation Restoration',
      restorationScopeId: 'app',
      home: const NavigationHomeScreen(),
      // Use onGenerateRoute for restoration
      // This ensures restoration works with navigation
      onGenerateRoute: (settings) {
        // If we have restoration data, we can restore specific screens
        if (settings.name == '/detail') {
          return MaterialPageRoute(
            builder: (context) => const DetailScreen(),
            settings: settings, // Pass settings for restoration
          );
        }
        return null;
      },
    );
  }
}

/// Home screen that navigates to detail screen
class NavigationHomeScreen extends StatelessWidget {
  const NavigationHomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Navigation Restoration'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'This app preserves navigation state',
              style: TextStyle(fontSize: 18),
            ),
            const SizedBox(height: 16),

            // Navigate to detail screen
            ElevatedButton(
              onPressed: () {
                // Push the detail screen
                Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) => const DetailScreen(),
                    // Important: Pass the settings for restoration
                    settings: const RouteSettings(name: '/detail'),
                  ),
                );
              },
              child: const Text('Go to Detail'),
            ),

            const SizedBox(height: 24),

            // Info box
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.blue[50],
                borderRadius: BorderRadius.circular(8),
              ),
              child: const Text(
                'The navigation stack is preserved across app restarts',
                textAlign: TextAlign.center,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

What's happening here? - Navigation stack is preserved - Detail screen state is restored - RouteSettings enable restoration - onGenerateRoute handles restoration


Restoration with Scroll Position

Restoring scroll position in lists.

/// List screen with scroll position restoration
class ScrollRestorationScreen extends StatefulWidget {
  const ScrollRestorationScreen({super.key});

  @override
  State<ScrollRestorationScreen> createState() => _ScrollRestorationScreenState();
}

/// State with scroll position restoration
class _ScrollRestorationScreenState extends State<ScrollRestorationScreen> 
    with RestorationMixin {
  // Restoration ID for this screen
  @override
  String get restorationId => 'scroll_screen';

  // Scroll controller for the list
  // We'll save and restore its position
  final ScrollController _scrollController = ScrollController();

  // Restorable value for the scroll position
  // This stores the current scroll offset
  final RestorableDouble _scrollOffset = RestorableDouble(0);

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

    // Listen to scroll changes
    _scrollController.addListener(() {
      // Update the restorable scroll offset
      // This value is automatically saved
      _scrollOffset.value = _scrollController.position.pixels;
    });
  }

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    // Register the scroll offset for restoration
    registerForRestoration(_scrollOffset, 'scroll_offset');

    // If we have a saved scroll offset, restore it
    // This ensures the list scrolls to the saved position
    WidgetsBinding.instance.addPostFrameCallback((_) {
      // Jump to the saved scroll position
      _scrollController.jumpTo(_scrollOffset.value);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll Restoration'),
      ),
      body: Column(
        children: [
          // Scroll position indicator
          Container(
            padding: const EdgeInsets.all(8),
            color: Colors.blue[50],
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text(
                  'Scroll Position:',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                Text(
                  _scrollOffset.value.toStringAsFixed(2),
                  style: const TextStyle(fontWeight: FontWeight.bold),
                ),
              ],
            ),
          ),

          // Scrollable list
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: 100,
              itemBuilder: (context, index) {
                return Container(
                  height: 60,
                  margin: const EdgeInsets.all(4),
                  decoration: BoxDecoration(
                    color: Colors.blue[100 * (index % 9 + 1)],
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Center(
                    child: Text(
                      'Item $index',
                      style: const TextStyle(
                        fontWeight: FontWeight.bold,
                        color: Colors.white,
                      ),
                    ),
                  ),
                );
              },
            ),
          ),

          // Control buttons
          Container(
            padding: const EdgeInsets.all(8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                // Scroll to top
                ElevatedButton(
                  onPressed: () {
                    _scrollController.animateTo(
                      0,
                      duration: const Duration(milliseconds: 500),
                      curve: Curves.easeInOut,
                    );
                  },
                  child: const Text('Top'),
                ),
                const SizedBox(width: 8),

                // Scroll to middle
                ElevatedButton(
                  onPressed: () {
                    // Scroll to the middle of the list
                    final maxScroll = _scrollController.position.maxScrollExtent;
                    _scrollController.animateTo(
                      maxScroll / 2,
                      duration: const Duration(milliseconds: 500),
                      curve: Curves.easeInOut,
                    );
                  },
                  child: const Text('Middle'),
                ),
                const SizedBox(width: 8),

                // Scroll to bottom
                ElevatedButton(
                  onPressed: () {
                    // Scroll to the bottom of the list
                    final maxScroll = _scrollController.position.maxScrollExtent;
                    _scrollController.animateTo(
                      maxScroll,
                      duration: const Duration(milliseconds: 500),
                      curve: Curves.easeInOut,
                    );
                  },
                  child: const Text('Bottom'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    // Dispose controllers and restorable objects
    _scrollController.dispose();
    _scrollOffset.dispose();
    super.dispose();
  }
}

What's happening here? - Scroll position is saved as RestorableDouble - Scroll listener updates the saved position - Post-frame callback restores scroll position - List continues where user left off


Restoration with Forms

Restoring form data and validation state.

/// Form screen with restoration support
class FormRestorationScreen extends StatefulWidget {
  const FormRestorationScreen({super.key});

  @override
  State<FormRestorationScreen> createState() => _FormRestorationScreenState();
}

/// State with full form restoration
class _FormRestorationScreenState extends State<FormRestorationScreen> 
    with RestorationMixin {
  // Restoration ID
  @override
  String get restorationId => 'form_screen';

  // Form key for validation
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  // Restorable form fields
  // Each field's value is saved and restored
  final RestorableString _name = RestorableString('');
  final RestorableString _email = RestorableString('');
  final RestorableString _phone = RestorableString('');
  final RestorableBool _agreeToTerms = RestorableBool(false);

  // Non-restorable state (UI only)
  bool _submitted = false;

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    // Register all form fields for restoration
    registerForRestoration(_name, 'form_name');
    registerForRestoration(_email, 'form_email');
    registerForRestoration(_phone, 'form_phone');
    registerForRestoration(_agreeToTerms, 'form_agree_terms');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Form Restoration'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          child: Column(
            children: [
              // Name field
              // The value is restored when the app restarts
              TextFormField(
                initialValue: _name.value,
                decoration: const InputDecoration(
                  labelText: 'Name',
                  border: OutlineInputBorder(),
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your name';
                  }
                  return null;
                },
                onChanged: (value) {
                  // Save the value for restoration
                  _name.value = value;
                },
              ),
              const SizedBox(height: 16),

              // Email field
              TextFormField(
                initialValue: _email.value,
                decoration: const InputDecoration(
                  labelText: 'Email',
                  border: OutlineInputBorder(),
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your email';
                  }
                  if (!value.contains('@')) {
                    return 'Please enter a valid email';
                  }
                  return null;
                },
                onChanged: (value) {
                  // Save the value for restoration
                  _email.value = value;
                },
              ),
              const SizedBox(height: 16),

              // Phone field
              TextFormField(
                initialValue: _phone.value,
                decoration: const InputDecoration(
                  labelText: 'Phone',
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.phone,
                onChanged: (value) {
                  // Save the value for restoration
                  _phone.value = value;
                },
              ),
              const SizedBox(height: 16),

              // Terms and conditions checkbox
              CheckboxListTile(
                title: const Text('I agree to the terms and conditions'),
                // The checkbox state is restored
                value: _agreeToTerms.value,
                onChanged: (value) {
                  // Save the value for restoration
                  _agreeToTerms.value = value ?? false;
                },
              ),
              const SizedBox(height: 16),

              // Submit button with validation
              ElevatedButton(
                onPressed: () {
                  // Validate the form
                  if (_formKey.currentState!.validate()) {
                    setState(() {
                      _submitted = true;
                    });

                    // Show success message
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(
                        content: Text('Form submitted successfully!'),
                        backgroundColor: Colors.green,
                      ),
                    );
                  }
                },
                child: const Text('Submit'),
              ),

              // Show submission status
              if (_submitted) ...[
                const SizedBox(height: 16),
                Container(
                  padding: const EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    color: Colors.green[50],
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: const Text(
                    'Form has been submitted!',
                    style: TextStyle(color: Colors.green),
                  ),
                ),
              ],

              const Spacer(),

              // Display current values
              Container(
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: Colors.blue[50],
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Column(
                  children: [
                    const Text(
                      'Saved Values:',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    const SizedBox(height: 4),
                    Text('Name: ${_name.value}'),
                    Text('Email: ${_email.value}'),
                    Text('Phone: ${_phone.value}'),
                    Text('Agreed: ${_agreeToTerms.value}'),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    // Dispose all restorable objects
    _name.dispose();
    _email.dispose();
    _phone.dispose();
    _agreeToTerms.dispose();
    super.dispose();
  }
}

What's happening here? - All form fields are restored - Validation state is preserved - Checkbox state is restored - User doesn't lose entered data


Best Practices

Use RestorationMixin Correctly

// Good - Proper restoration implementation
class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> with RestorationMixin {
  @override
  String get restorationId => 'my_widget';

  final RestorableInt _value = RestorableInt(0);

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_value, 'value');
  }
}

Dispose Restorable Objects

// Good - Proper disposal
@override
void dispose() {
  _value.dispose();
  _text.dispose();
  super.dispose();
}

Use Appropriate Restoration IDs

// Good - Unique restoration IDs
@override
String get restorationId => '${widget.id}_screen';

// Bad - Duplicate IDs can cause conflicts
@override
String get restorationId => 'screen';

Common Mistakes

Forgetting RestorationMixin

Wrong:

// Missing RestorationMixin
class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

Correct:

// With RestorationMixin
class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> with RestorationMixin {
  // Implementation
}

Not Disposing Restorable Objects

Wrong:

// Memory leak
final RestorableInt _value = RestorableInt(0);
// No dispose

Correct:

// Proper disposal
@override
void dispose() {
  _value.dispose();
  super.dispose();
}


Summary

Restoration preserves user state across app restarts. Use RestorationMixin with Restorable types to save and restore UI state, navigation, scroll positions, and form data. This creates a seamless user experience and handles system-initiated app kills gracefully.


Next Steps


Did You Know?

  • Restoration works across process kills
  • Android "Don't keep activities" is handled
  • Navigation stack can be restored
  • Scroll positions are preserved
  • Form data is maintained
  • Restorable types are automatically saved
  • Restoration IDs must be unique
  • dispose() prevents memory leaks