How to: Solid reactivity in SymbioteNative
React, Vue, Svelte, and Angular all re-invoke (or re-patch) a component’s
render output when its state changes. Solid does not - a component function
body runs exactly once, and everything reactive afterward flows through
signals read inside fine-grained effects that Solid tracks automatically.
This page is the app-author’s half of that model: what changes about the
code you write day to day. If you’re porting a package’s per-framework entry
to Solid instead, see the symbiote-solid-package-port skill - it covers the
same ideas from the library-author side, with more edge cases.
Read a prop, don’t destructure it
Section titled “Read a prop, don’t destructure it”A Solid props object is a set of getters, not plain values. Destructuring freezes whatever the prop held at the moment the component ran:
// WRONG - count is frozen at whatever value the caller passed on mountfunction Counter({ count }: { count: number }) { return <text>{count}</text>;}
// RIGHT - props.count is read fresh wherever it's usedfunction Counter(props: { count: number }) { return <text>{props.count}</text>;}The same applies to splitProps, which any component of your own built the same way
uses: it returns two prop proxies, not two plain objects, so reading from either side
still stays live.
A scalar you’ll re-read over time needs to become an accessor
Section titled “A scalar you’ll re-read over time needs to become an accessor”If a value only matters once - an initial count, a static label - a plain number/string prop is fine. The moment a caller might want to change it later and have your component notice, that prop has to be something Solid can track: a signal accessor, not a number.
// A caller cannot make this component react to a later change: the prop// value was read once (props.intervalMs) and nothing subscribes to it again.function Ticker(props: { intervalMs: number }) { ... }
// The accessor form lets EITHER a literal or a signal be passed -// solid-primitives' own convention, also used across this codebase for any// "a component body would re-read this on every render" param.type IMaybeAccessor<T> = T | Accessor<T>;function Ticker(props: { intervalMs: IMaybeAccessor<number> }) { const interval = () => typeof props.intervalMs === 'function' ? props.intervalMs() : props.intervalMs; createEffect(() => { const id = setInterval(tick, interval()); onCleanup(() => clearInterval(id)); });}This is exactly the shape createColorScheme()/createWindowDimensions()
(@symbiote-native/solid) return - an Accessor, never a bare value - for
the identical reason: a returned snapshot would freeze at whatever the app
booted with.
Render children take an accessor, not a snapshot
Section titled “Render children take an accessor, not a snapshot”pressable is now a bare tag — the press machine lives on the engine node, not in a
component body — so there is no render-prop child any more, on any adapter. A tag has
no function to call children({ pressed: true }) from, so state => ... as a pressable
child has no channel left. A descendant that needs press state gets it explicitly, by
mirroring onPressIn/onPressOut into a local signal instead:
const [pressed, setPressed] = createSignal(false);
<pressable onPressIn={() => setPressed(true)} onPressOut={() => setPressed(false)}> <text>{pressed() ? 'Release' : 'Press me'}</text></pressable>;A functional style still works without this — the engine resolves it at both values of
pressed on its own (isStyleCallback, core/engine/src/node.ts) — and a :active CSS
rule is cheaper still when the look, not the label, is what changes.
The accessor lesson this page is really about still applies wherever your own component
takes a render-prop child, and the mechanism is worth knowing before you write one. Solid
has no reconciler between what a component returns and the actual host nodes: insert
(the internal call every child position compiles to) replaces a subtree wholesale, it
never diffs one, the way React reconciles or Vue patches vnodes. So a render-prop component
calling children({ pressed: true }) with a plain value runs that call inside its
own render effect — any signal the call reads joins that effect’s dependency list, and
every change to it tears the whole child subtree down and rebuilds it from scratch. Hand
the child function an Accessor instead, and call it once, untracked:
// WRONG - a plain value ties the child subtree to the parent's render effect<MyRenderProp>{state => <text>{state.active ? 'on' : 'off'}</text>}</MyRenderProp>
// RIGHT - an Accessor, read fresh wherever it's used<MyRenderProp>{state => <text>{state().active ? 'on' : 'off'}</text>}</MyRenderProp>The fix generalizes: a value that changes over time must cross a component boundary as an accessor. Call it inside the leaf that actually needs the fresh read, not at the boundary. Two follow-on rules once you do:
- A signal read at the top level of the child function is frozen -
the call itself is untracked, matching how Solid’s own
<For>runs its map function undercreateRoot. Nest the read inside JSX (or a<Show>) instead, where the compiler’s own tracking picks it back up. typeof children === 'function'can’t tell a render-prop from a bare zero-argument accessor (JSX.Elementpermits both). Arity does the telling: a render prop takes the state argument, a plain accessor takes none.
None of this is a SymbioteNative invention - it is exactly what Solid’s own
<Show> does internally: untrack(() => child(keyed ? value : accessor)).
A ref is a callback, not a box to read later
Section titled “A ref is a callback, not a box to read later”React’s ref.current and Vue’s template ref are both objects you read
from after the fact. Solid’s ref is a compile-time construct - the
compiler rewrites ref={el} into a call to the renderer’s use helper
before your code ever runs, on a tag exactly as on a component:
<view ref={el} />;// compiles to:var _el$ = createElement('view');use(el, _el$);So there’s nothing to allocate and nothing to unwrap - use calls a function ref once
with the committed host node (and assigns a plain variable ref directly):
const [input, setInput] = createSignal<IHostInstance | null>(null);
<text-input ref={setInput} value={text()} onValueChange={setText} />;
createEffect(() => { const node = input(); if (node !== null) node.focus();});The node a ref receives already carries the imperative API - measure,
measureInWindow, setNativeProps, focus, blur - grafted on by the
renderer, the same way React’s getPublicInstance does it. Holding it in a
signal (as above) is idiomatic when something needs to react to the ref
resolving; a plain mutable variable is fine when nothing does.
<For> / <Index> / <Show> come straight from @symbiote-native/solid
Section titled “<For> / <Index> / <Show> come straight from @symbiote-native/solid”Solid’s built-in control-flow components are pure reactivity with no DOM dependency, so they work unchanged over the engine and are re-exported directly - one import root, same as every other runtime module:
import { For, Index, Show } from '@symbiote-native/solid';For keys by referential identity of each array element (reorders reuse
DOM/host nodes); Index keys by position (reuse by slot, contents update in
place) - pick For when your list items are themselves stable references
(objects from a store), Index for a list of primitives you’re happy to
have update in place rather than move. Show replaces a manual ternary
guard and is what makes a conditional’s condition get memoized by the
compiler (see wrapConditionals below) - reach for it over a raw &&/?:
buried in a helper function.
One name is deliberately absent: @symbiote-native/solid does not re-export Solid’s own
Switch/Match control-flow pair, historically to avoid a collision with RN’s Switch
primitive. Import Solid’s control-flow pair straight from solid-js instead (aliasing it
if you need both in one file).
Further reading
Section titled “Further reading”The full API surface - events, refs, portals, animations - is in the Solid
API reference. The Solid guide has
a minimal working component. Porting a package’s own per-framework entry to
Solid, rather than just consuming one, is a different and stricter set of
traps (ownership/timing, an empty collector at body time, mergeProps vs a
JS spread) - that side lives in the symbiote-solid-package-port skill, not
here.