solid-fuse

Handles

Imperative control via handles — scroll controllers and focus nodes

Some Flutter patterns are imperative — you can't express "scroll to offset 500" or "focus this input" as a prop. A handle bridges this gap: a persistent Dart object backing a JS-side reference that you can call methods on and read reactive state from. Two come built in — the scroll controller and the focus node.

ScrollController

createScrollController gives you a ScrollController you can pass to a ScrollView and drive imperatively. It also exposes the scroll offset as a reactive signal.

import { For } from "solid-js";
import {
  GestureDetector,
  ScrollView,
  Text,
  View,
  createScrollController,
} from "solid-fuse";

function ScrollableList() {
  const scroll = createScrollController();

  return (
    <View flex={{ expand: true }}>
      <View flex={{ direction: "horizontal", gap: 8 }} padding={8}>
        <GestureDetector onTap={() => scroll.animateTo(0, { duration: 300 })}>
          <View padding={8} decoration={{ color: "blue", borderRadius: 4 }}>
            <Text color="white">Top</Text>
          </View>
        </GestureDetector>
        <Text>Offset: {Math.round(scroll.scrollOffset())}</Text>
      </View>

      <ScrollView controller={scroll} flex={{ expand: true }}>
        <View flex={{ gap: 4 }} padding={16}>
          <For each={Array.from({ length: 100 }, (_, i) => i)}>
            {(i) => <Text>Item {i}</Text>}
          </For>
        </View>
      </ScrollView>
    </View>
  );
}

API

MethodDescription
scroll.jumpTo(offset)Jump to position immediately
scroll.animateTo(offset, { duration? })Smooth scroll to position (duration in ms, default 300)
scroll.scrollOffset()Reactive signal — current scroll position, updates as the user scrolls
scroll.dispose()Dispose the underlying Dart object. Automatic if created inside a reactive owner.

Initial offset

const scroll = createScrollController({ initialScrollOffset: 200 });

FocusNode

createFocusNode gives you a Flutter FocusNode you can pass to a TextField and drive imperatively. It exposes hasFocus as a reactive signal.

import { TextField, Text, View, createFocusNode } from "solid-fuse";

function FocusDemo() {
  const focus = createFocusNode();

  return (
    <View flex={{ gap: 6 }}>
      <TextField focusNode={focus} placeholder="Type here" />
      <Text>hasFocus = {focus.hasFocus() ? "true" : "false"}</Text>
      <GestureDetector onTap={() => focus.focus()}>
        <Text color="blue">Focus the field</Text>
      </GestureDetector>
      <GestureDetector onTap={() => focus.unfocus()}>
        <Text color="blue">Unfocus</Text>
      </GestureDetector>
    </View>
  );
}

API

MethodDescription
focus.focus()Request focus
focus.unfocus()Drop focus
focus.hasFocus()Reactive signal — true while the field is focused
focus.dispose()Dispose. Automatic if created inside a reactive owner.

How they work

Both are thin wrappers around the same handle system:

  1. createHandle("scrollController", { initialScrollOffset, setScrollOffset }) creates a JS-side { node, call, dispose } triple. The Dart side instantiates a FuseScrollController handle wrapping a real Flutter ScrollController.
  2. When you pass controller={scroll} to <ScrollView>, the renderer serializes the reference as _node: <id>. The Dart ScrollView widget reads it as a FuseNode reference, looks up the handle, and uses its wrapped ScrollController.
  3. State updates from Dart (the user scrolls) call the setScrollOffset callback you passed in, which updates a Solid signal.
  4. Imperative calls (scroll.animateTo(...)) go over a _handleCall channel that targets the node by id.

The signal updates are not lazy — every Dart-side change calls into JS. Use Math.round or wrap reads in your own createMemo if you want to throttle re-renders.

Building your own

You can create handles for any persistent native Dart object — animation controllers, page route observers, native plugin instances. See Creating handles for the full pattern.

On this page