Skip to content

Solid API

Primitives (view, text, pressable, text-input, scroll-view, …) are plain lowercase intrinsic tags — write them directly in JSX, no import needed. Import only what’s still a real component or module from @symbiote-native/solid:

import { StyleSheet } from '@symbiote-native/solid';

Solid drives the same engine as React/Vue/Svelte/Angular, through solid-js/universal’s own createRenderer - the official, framework-shipped seam for a non-DOM target. Compiled Solid JSX imports eleven functions (createElement, insert, setProp, …) straight from @symbiote-native/solid/renderer, and each one is a thin wrapper over the engine’s mutation API. A Solid component body runs once; everything that changes afterward has to cross as a signal or accessor, not a re-invocation. See the reactivity guide before writing a component that holds state.

Terminal window
pnpm add @symbiote-native/solid react-native solid-js

react-native and solid-js stay your app’s own top-level dependencies - react-native is the Metro version anchor, so it has to be pinned at the app root. @symbiote-native/engine is a peer dependency and installs alongside.

Two things Metro/Babel/tsc all need, none optional:

  • babel.config.js - add @symbiote-native/solid/babel-preset to presets, listed last (Babel applies presets in reverse array order, so last means “runs first”). It has to win the JSX before @react-native/babel-preset’s own React-JSX transform claims the same elements. Swap the order and you get createElement calls no renderer in the app implements. The preset arrives pre-configured (generate: 'universal' + the renderer’s moduleName); don’t override either option yourself.
  • tsconfig.json - "jsx": "preserve", "jsxImportSource": "@symbiote-native/solid". TypeScript resolves the JSX namespace from <jsxImportSource>/jsx-runtime and nowhere else, even under preserve. Without this the package’s own host-tag namespace (symbiote-view, symbiote-text, …) isn’t in scope and every JSX element fails to type check.

examples/solid is the reference for the full config, including metro.config.js.

  • Events: real camelCase props (onPress, onLongPress, onValueChange, onLayout) passed straight through the compiled JSX, same as Svelte’s shape, no directive and no kebab-case step.
  • Children: ordinary JSX, built once per component body. pressable is a plain tag, so pressed state no longer arrives as a render-prop function child — wire onPressIn/onPressOut into a signal instead. List components (FlatList, SectionList, …) still take a function child. See Children below.
  • Refs: ref={callback}, a compile-time rewrite into a callback prop, not a runtime ref object. See Refs and handles below.
  • Styles: style/class props, resolved through the same style registry every other adapter’s class/className/:class path shares.
  • Two-way binding has no dedicated syntax: TextInput/Switch/ Slider take the explicit value/ onValueChange pair; wire it to a signal yourself (value={text()} onValueChange={setText}).
<pressable onPress={onTap} onLongPress={onHold} />
<switch value={enabled()} onValueChange={setEnabled} />
<text-input
value={text()}
onValueChange={(next, event) => onChange(event)}
/>
<view onLayout={onLayout} />

TextInput’s onValueChange fires (text, event), the same merged shape React and Svelte use, unlike Angular, which needs a second output because an EventEmitter only carries one value.

pressable is a plain tag, so there’s no component body left to call a render-prop function child against — wire the two press events into a signal instead; reading it inside the JSX stays reactive the same way reading an Accessor would:

import { createSignal } from 'solid-js';
const [pressed, setPressed] = createSignal(false);
<pressable
onPress={onTap}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
>
<text>{pressed() ? 'Release' : 'Press me'}</text>
</pressable>;

List components (FlatList, SectionList, …) still take a genuine render function per cell — see Solid reactivity for that pattern and the device bug it was built to avoid.

List renderers follow the same principle: the data and native list math are shared with every other adapter, while the rendered cell is ordinary Solid JSX (<For>/<Index>) rather than a React node, Vue slot, or Svelte snippet.

import { createSignal } from 'solid-js';
import type { IHostInstance } from '@symbiote-native/solid';
const [input, setInput] = createSignal<IHostInstance | null>(null);
<text-input ref={setInput} value="" onValueChange={() => {}} />;

Solid’s ref={el} is a compile-time construct: the compiler rewrites it into a callback prop (createComponent(TextInput, { ref(node) { ... } })), so by the time a component body reads props.ref it is already a function. There is no RefObject/template-ref wrapper to allocate: the callback is called once with the committed host node, which carries the grafted imperative API (measure, measureInWindow, setNativeProps, focus, blur) exactly like React’s getPublicInstance. Holding it in a signal (as above) is the idiomatic way to keep it across the component’s lifetime; a plain let variable also works if nothing needs to react to the ref resolving.

A component with its own exported imperative surface (ScrollView.scrollTo/scrollToEnd/flashScrollIndicators, TextInput’s focus/blur/clear) exposes that surface directly on the ref, same as every other adapter.

@symbiote-native/solid re-exports the same runtime utilities as the other adapters, so app code keeps one import root: Platform, StyleSheet, Dimensions, PixelRatio, PlatformColor, DynamicColorIOS, Alert, Share, ActionSheetIOS, Linking, Vibration, ToastAndroid, Settings, I18nManager, Appearance, AppState, Keyboard, BackHandler, PermissionsAndroid, LayoutAnimation, InteractionManager, AccessibilityInfo, findNodeHandle, and dlog/isDebug for diagnostic logging.

Two reactive reads are primitives, not hooks: Solid’s ecosystem term for a composable reactive function, and why this adapter’s lifecycle bucket is named primitives/.

import {
createColorScheme,
createWindowDimensions,
} from '@symbiote-native/solid';
const scheme = createColorScheme();
const dimensions = createWindowDimensions();
<text>
{dimensions().width}×{dimensions().height} · {scheme()}
</text>;

Each returns an accessor, never a bare value: a Solid component body runs once, so a returned snapshot would freeze at whatever the app booted with. Named create*, not use*: Solid reserves use* for consuming something that already exists (useContext, useTransition); a function that owns its own subscription is create*. useNavigation/useRoute from @symbiote-native/navigation are the use* counterpart: they consume an existing router rather than creating one.

On Android, Keyboard and Settings do nothing until you also install @symbiote-native/android: keyboardDidShow/keyboardDidHide never fire, because Android’s only stock keyboard-event source is ReactRootView’s layout listener and the bridgeless surface SymbioteNative mounts never triggers it, and Settings reads back null, because RN’s Settings has no stock Android implementation at all.

Animated (both the JS and native driver, plus the Animated.View/ Animated.Text/Animated.Image/Animated.ScrollView/Animated.FlatList/ Animated.SectionList components) and PanResponder are re-exported from @symbiote-native/solid. See the Animations guide for the full surface. Solid’s JSX takes the dotted form directly (<Animated.View> compiles to createComponent(Animated.View, ...)), unlike Angular, whose AOT compiler needs a named import instead.

import { createSignal } from 'solid-js';
import { Portal, Show, type ISymbioteNode } from '@symbiote-native/solid';
const [overlayHost, setOverlayHost] = createSignal<ISymbioteNode | null>(null);
<view ref={node => setOverlayHost(node)} />;
<Show when={overlayHost()}>
{host => (
<Portal mount={host()}>
<text>Rendered under overlayHost, not here</text>
</Portal>
)}
</Show>;

Portal relocates its children under a different node than its own call site: the same primitive as React’s createPortal, Angular’s PortalDirective/PortalOutletDirective pair, Vue’s Teleport, and Svelte’s own Portal. mount takes an already-mounted host node (or a surface, from AppRegistry), never a selector string: Solid’s own solid-js/web Portal resolves a DOM container the same way ours resolves a ref, so the spelling matches what a Solid author already knows. Same v1 scope as React’s and Angular’s: the target must live in the same surface as the <Portal> call site. Gate the Portal behind <Show> until the target ref resolves - reading an unset mount throws immediately rather than silently corrupting the tree.

import { createTunnel, Show } from '@symbiote-native/solid';
const overlayTunnel = createTunnel(); // module-level singleton, importable from both surfaces
// inside the surface that should paint the content
<overlayTunnel.Out />;
// inside any other component, in any surface
<Show when={toastVisible()}>
<overlayTunnel.In>
<text>Toast</text>
</overlayTunnel.In>
</Show>;

Portal only reaches a target in the same surface. createTunnel is for two independently mount()-ed surfaces that share no Fabric tree at all. In and Out are per-call components closed over the tunnel’s own registry. Solid components are ordinary functions, so createTunnel() mints a fresh pair on every call, the same shape React’s and Vue’s versions use. In registers its content wherever it renders and paints nothing itself; Out renders everything currently tunneled in, in registration order, wherever it is actually mounted.

import { AppRegistry } from '@symbiote-native/solid';
AppRegistry.registerComponent('MyApp', () => App);

@symbiote-native/solid exports the same AppRegistry/setHostRegistrar entry point described in the Core API. setWrapperComponentProvider is fully supported here, unlike Svelte: a Solid component is an ordinary function, so wrapping composes exactly the way compiled JSX already builds children: createComponent(Wrapper, { get children() { return renderRoot(); } }), with no extra design needed:

import type { Component, JSX } from 'solid-js';
import { AppRegistry } from '@symbiote-native/solid';
const ThemeWrapper: Component<{ children?: JSX.Element }> = props => (
<ThemeProvider>{props.children}</ThemeProvider>
);
AppRegistry.setWrapperComponentProvider(() => ThemeWrapper);
AppRegistry.registerComponent('MyApp', () => App);

Do not pass React component packages to the Solid adapter. A third-party React Native package that ships a JavaScript React component still uses the React dispatcher internally. Non-React adapters need native-view wrappers instead. See the Slider package for the reference implementation, shipping on React, Vue, Angular, Svelte, and Solid alike.