Skip to main content

useScrollController

Creates a ScrollController that is disposed automatically when the hook context unmounts. It returns the controller, ready to attach to a scrollable.

class ScrollToTopList extends HookWidget {
const ScrollToTopList({super.key});


Widget build(BuildContext context) {
final scrollController = useScrollController(); // <- Created once, disposed for you

return Scaffold(
body: ListView.builder(
controller: scrollController,
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
floatingActionButton: FloatingActionButton(
onPressed: () => scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
child: const Icon(Icons.arrow_upward),
),
);
}
}

Signature

ScrollController useScrollController({
double initialScrollOffset = 0.0,
bool keepScrollOffset = true,
String? debugLabel,
HookKeys keys = hookKeysEmpty,
});

The parameters mirror the ScrollController constructor. keys behaves as elsewhere in the library: when the keys change, the controller is disposed and a fresh one is created (resetting the offset) - useful when one widget reuses a list for distinct data sets that should each start at the top.

Use cases

  • Programmatic scrolling: jump or animate to an offset (scroll-to-top, scroll-to-index) from a callback.

  • Reading scroll position to drive UI - showing a "back to top" button past a threshold, or triggering a paginated load near the end:

    class LoadMoreList extends HookWidget {
    const LoadMoreList({super.key});


    Widget build(BuildContext context) {
    final scrollController = useScrollController();

    // React to scroll without rebuilding on every pixel - useListenableListener,
    // not useListenable.
    useListenableListener(scrollController, () {
    final position = scrollController.position;
    if (position.pixels > position.maxScrollExtent - 200) {
    // load the next page...
    }
    });

    return ListView(controller: scrollController, children: const []);
    }
    }

    For a full paginated list, prefer usePaginatedComputedState with PaginatedComputedStateWrapper, which manages the scroll-driven loadMore for you.

Caveats

  • To react to scrolling, attach a listener with useListenableListener, not useListenable. A ScrollController notifies on every scroll frame; rebuilding the whole widget on each is wasteful, so run a side effect and read controller.offset inside the callback.

    // DON'T - rebuilds the widget on every scroll frame
    useListenable(scrollController);

    // DO - side effect only, no rebuild
    useListenableListener(scrollController, () {
    if (scrollController.offset > 200) showBackToTop.value = true;
    });
  • This wraps useMemoized(ScrollController.new, keys, (it) => it.dispose()). The hand-written form works, but the dedicated hook forwards every constructor argument and wires disposal and the keys-based reset - prefer it.

  • Reading controller.position (or offset) before the controller is attached to a scrollable throws. Read it from a listener or a post-attach callback, not during the first build.

See also