flutter-hooks

SKILL.md

Why Flutter Hooks

This project includes flutter_hooks: ^0.21.2. Benefits:

  • Less boilerplate - No State classes
  • Auto cleanup - Controllers disposed automatically
  • More composable - Share logic easily
  • Cleaner code - No setState(), lifecycle methods

Basic Pattern

Traditional vs Hooks

Traditional (Verbose):

class MyWidget extends StatefulWidget {
  
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  late TextEditingController _controller;

  
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return TextField(controller: _controller);
  }
}

Hooks (Clean):

class MyWidget extends HookWidget {
  
  Widget build(BuildContext context) {
    final controller = useTextEditingController(); // Auto-disposed!
    return TextField(controller: controller);
  }
}

Common Hooks

useState - Simple State

final count = useState(0);
// Access: count.value
// Update: count.value++

useTextEditingController - Text Fields

final controller = useTextEditingController();
// Auto-disposed when widget removed

useEffect - Side Effects

useEffect(() {
  // Setup
  controller.addListener(listener);

  // Cleanup (returned function)
  return () => controller.removeListener(listener);
}, [controller]); // Dependencies

useMemoized - Cache Expensive Operations

final filtered = useMemoized(
  () => items.where((item) => item.isActive).toList(),
  [items], // Only recomputes when items change
);

useAnimationController - Animations

final animController = useAnimationController(
  duration: Duration(milliseconds: 300),
);
// No vsync needed, auto-disposed!

Real-World Examples

Form with Validation

class ProfileForm extends HookWidget {
  
  Widget build(BuildContext context) {
    final theme = IosTheme.of(context);
    final nameController = useTextEditingController();
    final emailController = useTextEditingController();
    final isValid = useState(false);
    final isLoading = useState(false);

    // Auto-validation
    useEffect(() {
      void validate() {
        isValid.value = nameController.text.isNotEmpty &&
                       emailController.text.contains('@');
      }

      nameController.addListener(validate);
      emailController.addListener(validate);

      return () {
        nameController.removeListener(validate);
        emailController.removeListener(validate);
      };
    }, [nameController, emailController]);

    return Column(
      children: [
        GroupedTableWidget(
          rows: [
            CupertinoTextFieldWidget(
              placeholder: 'Name',
              controller: nameController,
            ),
            CupertinoTextFieldWidget(
              placeholder: 'Email',
              controller: emailController,
            ),
          ],
        ),
        ButtonWidget.label(
          size: const LargeButtonSize(),
          color: const BlueButtonColor(),
          label: 'Save',
          displayCupertinoActivityIndicator: isLoading.value,
          onPressed: !isValid.value || isLoading.value
              ? null
              : () async {
                  isLoading.value = true;
                  await Future.delayed(Duration(seconds: 2));
                  isLoading.value = false;
                },
        ),
      ],
    );
  }
}

Search with Debounce

class SearchScreen extends HookWidget {
  
  Widget build(BuildContext context) {
    final searchController = useTextEditingController();
    final results = useState<List<String>>([]);
    final isSearching = useState(false);

    useEffect(() {
      Timer? debounce;

      void search() {
        debounce?.cancel();
        debounce = Timer(Duration(milliseconds: 500), () async {
          if (searchController.text.isEmpty) {
            results.value = [];
            return;
          }

          isSearching.value = true;
          // Simulate API call
          await Future.delayed(Duration(seconds: 1));
          results.value = ['Result 1', 'Result 2', 'Result 3'];
          isSearching.value = false;
        });
      }

      searchController.addListener(search);
      return () {
        debounce?.cancel();
        searchController.removeListener(search);
      };
    }, [searchController]);

    return Column(
      children: [
        CupertinoSearchTextFieldWidget(controller: searchController),
        if (isSearching.value) CupertinoActivityIndicator(),
        Expanded(
          child: ListView.builder(
            itemCount: results.value.length,
            itemBuilder: (context, index) => RowWidget.standard(
              title: results.value[index],
              onPressed: () {},
            ),
          ),
        ),
      ],
    );
  }
}

Theme Switcher

class ThemeSwitcher extends HookWidget {
  
  Widget build(BuildContext context) {
    final isDark = useState(false);

    // Memoize theme (only recreates when isDark changes)
    final themeData = useMemoized(
      () => isDark.value ? IosDarkThemeData() : IosLightThemeData(),
      [isDark.value],
    );

    return IosAnimatedTheme(
      data: themeData,
      child: ScaffoldWidget(
        navigationBar: CupertinoNavigatorBarWidget(title: 'Theme'),
        child: SwitchWidget(
          value: isDark.value,
          onChanged: (value) => isDark.value = value,
        ),
      ),
    );
  }
}

Infinite Scroll

class InfiniteList extends HookWidget {
  
  Widget build(BuildContext context) {
    final items = useState<List<String>>([]);
    final isLoading = useState(false);
    final scrollController = useScrollController();

    // Load more when near bottom
    useEffect(() {
      void checkScroll() {
        if (scrollController.position.pixels >=
            scrollController.position.maxScrollExtent - 200) {
          if (!isLoading.value) {
            _loadMore(items, isLoading);
          }
        }
      }

      scrollController.addListener(checkScroll);
      return () => scrollController.removeListener(checkScroll);
    }, [scrollController]);

    // Initial load
    useEffect(() {
      _loadMore(items, isLoading);
      return null;
    }, []);

    return ListView.builder(
      controller: scrollController,
      itemCount: items.value.length + (isLoading.value ? 1 : 0),
      itemBuilder: (context, index) {
        if (index >= items.value.length) {
          return CupertinoActivityIndicator();
        }
        return RowWidget.standard(
          title: items.value[index],
          onPressed: () {},
        );
      },
    );
  }

  Future<void> _loadMore(
    ValueNotifier<List<String>> items,
    ValueNotifier<bool> isLoading,
  ) async {
    isLoading.value = true;
    await Future.delayed(Duration(seconds: 1));
    items.value = [
      ...items.value,
      ...List.generate(20, (i) => 'Item ${items.value.length + i}'),
    ];
    isLoading.value = false;
  }
}

Tabbed Interface

class TabbedScreen extends HookWidget {
  
  Widget build(BuildContext context) {
    final selectedTab = useState(0);

    return ScaffoldWidget(
      toolBar: ToolBarWidget(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            for (int i = 0; i < 3; i++)
              ButtonWidget.label(
                size: const MediumButtonSize(),
                color: selectedTab.value == i
                    ? const BlueButtonColor()
                    : const GreyTransparentButtonColor(),
                label: 'Tab ${i + 1}',
                onPressed: () => selectedTab.value = i,
              ),
          ],
        ),
      ),
      child: _buildContent(selectedTab.value),
    );
  }

  Widget _buildContent(int tab) => Center(
    child: Text('Tab $tab Content'),
  );
}

Migration from StatefulWidget

Steps

  1. Change extends StatefulWidgetextends HookWidget
  2. Remove State class
  3. Replace initState with useEffect(() { ... return null; }, [])
  4. Replace dispose with effect cleanup return () => ...
  5. Replace setState with useState
  6. Replace controllers with hooks (useTextEditingController(), etc.)

Checklist

  • Import flutter_hooks
  • Extend HookWidget
  • Use hooks in build()
  • Return cleanup in useEffect
  • Specify dependencies
  • Test both light/dark modes

Hook Reference

useState<T>(T initialValue)
useTextEditingController({String? text})
useScrollController({double initialOffset})
useAnimationController({Duration? duration})
useMemoized<T>(T Function() compute, [keys])
useEffect(Function() effect, [keys])
useCallback<T>(T Function() callback, [keys])
useFuture<T>(Future<T> future)
useStream<T>(Stream<T> stream)

Import

import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:ios_design_system/ios_design_system.dart';
Weekly Installs
1
GitHub Stars
18
First Seen
Mar 2, 2026
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1