Skip to content

How to: refs and attachments in Svelte

Svelte has no React-style ref prop and no Vue template ref. Symbiote’s primitives (view, text, pressable, …) are ordinary lowercase host tags, so reaching the real native node from app code takes one of two mechanisms depending on what you’re touching.

bind:this — direct, on any primitive tag

Section titled “bind:this — direct, on any primitive tag”

bind:this on a primitive tag gives you back a real ShimElement directly — no wrapper in the way. hostInstance() unwraps that into the imperative host-instance API (measure, measureInWindow, setNativeProps, focus, blur), and findNodeHandle() reads the committed native tag off it — the tag only exists after the first commit, so read it from an $effect:

<script lang="ts">
import {
findNodeHandle,
hostInstance,
type ShimElement,
} from '@symbiote-native/svelte';
let box = $state.raw<ShimElement | null>(null);
let tag = $state<number | null>(null);
$effect(() => {
if (box === null) return;
tag = findNodeHandle(box);
});
function onMeasure(): void {
const instance = hostInstance(box);
if (instance === undefined) return;
instance.measure((x, y, width, height, pageX, pageY) => {
// real on-screen frame
});
}
</script>
<view testID="ref-box" bind:this={box}> ... </view>

Source: examples/svelte/components/RefApiDemo.svelte — the port of React’s RefApiDemo.tsx, backing measure/setNativeProps/findNodeHandle.

Some components expose their own imperative surface without any of this: ScrollView reads its own bind:this internally and exports plain functions — scrollTo/scrollToEnd/flashScrollIndicators/getScrollNode — the Svelte-5 twin of React’s useImperativeHandle/Vue’s expose(). Check the Components API for a component’s own exported surface before reaching for the raw host tag.

{@attach} — the route for a component, and it works on a tag too

Section titled “{@attach} — the route for a component, and it works on a tag too”

On a primitive tag, {@attach} is nothing but Svelte’s own compiler feature working directly against the element — no adapter code involved, same as bind:this:

<script>
const logLifecycle = node => {
console.log('attached', node);
return () => console.log('detached', node);
};
</script>
<view testID="target" {@attach logLifecycle} />

Where it earns its keep is the handful of primitives that stay real components (Modal, KeyboardAvoidingView, the list family) — the Svelte compiler rejects use:/transition:/class:/style: on a component (“This type of directive is not valid on components”), so {@attach fn} is the one directive-shaped construct that still compiles there: it lands as a prop keyed by a real JS Symbol (createAttachmentKey()), which rides through $props()/...rest untouched — routeProp only ever walks string keys — and each of those components spreads ...rest onto its own host tag, forwarding it there for free. From there, the adapter’s own createAttachmentsSync() wires it to the real committed node (adapters/svelte/src/runes/attachments.ts).

fromAction from svelte/attachments converts an ordinary Svelte action (init/update/destroy) into an attachment, so a library’s action-based API still works against a Symbiote primitive:

<script>
import { fromAction } from 'svelte/attachments';
const action = (node, value) => ({
update: next => {
/* react to a new value */
},
destroy: () => {
/* teardown */
},
});
</script>
<view testID="action-target" {@attach fromAction(action, () => someValue)} />

Source: adapters/svelte/src/runes/attachments.smoke.test.ts, which round-trips this exact shape end to end against a real compiled component.

Setting props on a <svelte:element> native leaf

Section titled “Setting props on a <svelte:element> native leaf”

@symbiote-native/navigation‘s stack renders react-native-screens’ native views (RNSScreen, RNSScreenStackHeaderConfig, RNSSearchBar, …) through <svelte:element this={'RNSScreen'}>, because a literal <RNSScreen> in a template would parse as a component reference, not an element. A dynamic tag compiles through Svelte’s generic setAttribute codegen instead of the custom-element p= property-set path, so p={bag} as a plain attribute silently does nothing there. An attachment sidesteps that path entirely by assigning the property from plain JS:

packages/navigation/src/svelte/attachments.ts
export function hostProps(
props: Record<string, unknown>,
): (node: unknown) => void {
return node => {
if (!isShimElement(node)) return;
node.p = props;
};
}
<svelte:element this={"RNSScreen"} {@attach hostProps(plan.screenProps)}>
...
</svelte:element>
You have… Use
A primitive tag (view, pressable, …) and you just need the node bind:this (or {@attach} — both are direct)
A component with its own exported imperative functions (ScrollView.scrollTo, …) that component’s own bind:this
A primitive that still stays a component (Modal, KeyboardAvoidingView, the list family) {@attach}
A third-party Svelte action {@attach fromAction(action, () => arg)}
A <svelte:element> native leaf (a capitalized, un-hyphenated native view name) {@attach} setting node.p