Skip to content

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.

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 mount
function Counter({ count }: { count: number }) {
return <text>{count}</text>;
}
// RIGHT - props.count is read fresh wherever it's used
function 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 under createRoot. 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.Element permits 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).

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.