Creating Handles
Build imperative Dart objects accessible from JS
A handle is a persistent native Dart object (controllers, managers, connections) that JS can call methods on and receive state updates from. Use one when you need imperative control that doesn't fit the declarative widget model — anything backed by a long-lived Dart object behind a JS-side reference. The built-ins createScrollController and createFocusNode are thin wrappers around the same primitives.
When to create a handle
- You need a Dart object that persists across renders (
ScrollController,FocusNode,AnimationController) - JS needs to call methods on it (
jumpTo,requestFocus,forward) - Dart needs to push state back to JS reactively (
scrollOffset,hasFocus,animationValue)
JS side: createHandle
createHandle(type, props) allocates a Dart-side object and returns three things:
import { createSignal } from "solid-js";
import { createHandle, type Handle } from "solid-fuse";
export type FocusNode = Handle<"focusNode"> & {
hasFocus: () => boolean;
focus: () => void;
unfocus: () => void;
dispose: () => void;
};
export function createFocusNode(): FocusNode {
const [hasFocus, setHasFocus] = createSignal(false);
// setHasFocus is a function — it's stored in the JS handler map and
// exposed to Dart, which calls it whenever the FocusNode's value changes.
const { node, call, dispose } = createHandle("focusNode", { setHasFocus });
return {
node,
hasFocus,
focus: () => call("focus"),
unfocus: () => call("unfocus"),
dispose,
};
}node
A FuseNode<K> reference. Include it on the returned object — widgets that accept the handle (like <TextField focusNode={focus} />) read this to serialize a _node: <id> ref over the bridge.
The K type parameter ("focusNode" here) discriminates handle kinds at prop sites, so a ScrollController can't be passed where a FocusNode is expected.
call(method, value?, options?)
Sends an imperative RPC to the Dart handle. Returns a promise that resolves with the Dart handler's return value.
call("animateTo", { offset: 500, duration: 300 });Default timeout is 30 seconds. Pass { timeout: 0 } for long-running native operations.
dispose()
Idempotent. If createHandle is called inside a reactive owner, dispose is wired to onCleanup automatically — explicit calls are only needed for handles owned outside any owner.
Pushing state from Dart with setters
There's no built-in "state signal" primitive — state from Dart flows through ordinary function props. Pass a setter from createSignal as a prop to createHandle; the Dart side invokes it whenever the value changes:
const [hasFocus, setHasFocus] = createSignal(false);
const { node, call, dispose } = createHandle("focusNode", { setHasFocus });
// In JSX — subscribes, re-renders when Dart pushes
<Text>{hasFocus() ? "Focused" : "Blurred"}</Text>The function is stored in the JS handler map and surfaces on the Dart side as a callable on the node. Functions are not serialized — only a marker that one exists.
Plain-value props go inline
Plain (JSON-serializable) props passed to createHandle land inline in the create op so the Dart factory can read them immediately at construction time:
createHandle("scrollController", {
initialScrollOffset: 120, // read by Dart constructor
setScrollOffset, // setter callback
});On the Dart side, the constructor sees node.double('initialScrollOffset') populated before object is built.
Dart side: FuseHandle<T>
Implement FuseHandle<T> where T is the type of the wrapped native object. The base class gives you the node, a call hook, and a dispose hook.
import 'package:flutter/widgets.dart';
import 'package:solid_fuse/solid_fuse.dart';
class FuseFocusNode extends FuseHandle<FocusNode> {
FuseFocusNode(super.node) : object = FocusNode() {
object.addListener(
() => node.callback('setHasFocus')?.call(object.hasFocus),
);
}
@override
final FocusNode object;
@override
Future<dynamic> call(String method, dynamic value) async {
switch (method) {
case 'focus':
object.requestFocus();
case 'unfocus':
object.unfocus();
default:
throw StateError('Unknown focusNode method: $method');
}
return null;
}
@override
void dispose() => object.dispose();
}object
The wrapped native Dart object — FocusNode, ScrollController, a route, whatever. Initialized in the constructor (use final if you need listener wiring at creation time) or late final for purely lazy resources.
object is what node.handle<T>('propName') returns on the consuming widget side, so it's the value other widgets receive when you pass controller={ctrl}.
call(method, value)
Handles imperative RPCs from JS. The return value is forwarded as the resolution of the JS call(...) promise.
dispose()
Called when the node is removed from the registry (JS dispose() is called, or the owning component unmounts). Release native resources here.
Calling JS setters from Dart
State updates back to JS go through node.callback(name), which returns a callable if the prop was set, or null otherwise:
node.callback('setScrollOffset')?.call(controller.offset);The callback name is the same string you passed in createHandle({ ... }) on the JS side.
How handle references resolve
When JS sets a widget prop to a value carrying .node (like <ScrollView controller={scroll}>), the renderer serializes the reference as _node: <id>. Dart hydrates it back into a FuseNode reference, and widgets can pull the wrapped object out with node.handle<T>(propName):
final controller = node.handle<ScrollController>('controller');
return SingleChildScrollView(controller: controller, child: ...);The node.handle<T> accessor null-checks the type — if a FocusNode ends up on a controller prop expecting ScrollController, you get null (with a debug-mode warning) instead of a crash.
Cleanup
Handles created inside a Solid reactive context (inside a component or createRoot) automatically dispose when the owner is cleaned up:
function MyComponent() {
const scroll = createScrollController(); // auto-disposes on unmount
return <ScrollView controller={scroll} />;
}Handles created outside a reactive context (module-level, manual scripts) won't auto-dispose. Call dispose() yourself if the handle manages resources.
Registration
runtime.registerHandle('focusNode', FuseFocusNode.new);The lifecycle: on create op the runtime instantiates the handle, stores it on the node. On _handleCall channel messages, it delegates to handle.call(...). On dispose op, it calls handle.dispose().
ScrollController: the complete example
The built-in ScrollController is a good reference for the full pattern:
import { createSignal } from "solid-js";
import { createHandle, type Handle } from "solid-fuse";
export type ScrollController = Handle<"scrollController"> & {
scrollOffset: () => number;
animateTo: (offset: number, opts?: { duration?: number }) => void;
jumpTo: (offset: number) => void;
dispose: () => void;
};
export function createScrollController(
opts: { initialScrollOffset?: number } = {},
): ScrollController {
const [scrollOffset, setScrollOffset] = createSignal(
opts.initialScrollOffset ?? 0,
);
const { node, call, dispose } = createHandle("scrollController", {
...opts,
setScrollOffset,
});
return {
node,
scrollOffset,
animateTo: (offset, o) => call("animateTo", { offset, ...o }),
jumpTo: (offset) => call("jumpTo", offset),
dispose,
};
}class FuseScrollController extends FuseHandle<ScrollController> {
FuseScrollController(FuseNode node)
: object = ScrollController(
initialScrollOffset: node.double('initialScrollOffset') ?? 0,
),
super(node) {
object.addListener(
() => node.callback('setScrollOffset')?.call(object.offset),
);
}
@override
final ScrollController object;
@override
Future<dynamic> call(String method, dynamic value) async {
switch (method) {
case 'animateTo':
final map = FuseMap.from(value)!;
object.animateTo(
map.double('offset') ?? 0,
duration: Duration(milliseconds: map.int('duration') ?? 300),
curve: Curves.easeInOut,
);
case 'jumpTo':
object.jumpTo((value as num).toDouble());
default:
throw StateError('Unknown scrollController method: $method');
}
return null;
}
@override
void dispose() => object.dispose();
}