# SDK reference

Everything your app can import from @squiggle.sh/react-native. Only the import is required; the rest tells Squiggle things it can't work out on its own.

## The import

```shell
import '@squiggle.sh/react-native';
```

It must be the first line of your entry file, before anything loads React. `squiggle init` puts it there. See [Set up your app](https://squiggle.sh/docs/setup).

## withSquiggle

From `@squiggle.sh/react-native/metro`. It wraps your Metro config so that a development build times each module your app evaluates while it starts. The report card uses those times to name the screens your app loads at startup though it doesn’t open on them. It adds nothing to a production bundle. `squiggle init` adds it.

```ts
const { getDefaultConfig } = require('expo/metro-config');
const { withSquiggle } = require('@squiggle.sh/react-native/metro');

const config = getDefaultConfig(__dirname);

module.exports = withSquiggle(config);
```

Wrap the config your file exports last. It changes the config you pass and returns it, so it works inside or outside other `with…` wrappers. After you add it, restart Metro with `--clear`. Until the bundle is rebuilt, the report card’s startup row stays pending.

## <Squiggle>

Wrap your root component in it. It tracks the current screen, feeds the heatmap every touch, and adds the on-device overlay in development builds.

```ts
import { Squiggle } from '@squiggle.sh/react-native';
import { Stack, useNavigationContainerRef } from 'expo-router';

export default function RootLayout() {
  return (
    <Squiggle navigationRef={useNavigationContainerRef()}>
      <Stack />
    </Squiggle>
  );
}
```

| Prop | What it does |
| --- | --- |
| `navigationRef` | Your navigation container ref: useNavigationContainerRef() with Expo Router, or your NavigationContainer ref with React Navigation. Without it, Squiggle can’t tell which screen is showing. |
| `overlay` | Show the on-device overlay. Default true. It only ever draws in a development build. |
| `touchCapture` | Send every touch to the heatmap. Default true. |
| `touchDetectors` | Check every touch for rage taps and dead zones, not only touches on gesture-handler controls. A touch counts as a tap only if it lifts where it went down, so scrolls never count. Default true. |

Keep `GestureHandlerRootView` at your root as usual, with `<Squiggle>` inside it.

## defineAction

Names something a user does. Each call is timed until it finishes, and recorded as succeeded or failed. Named actions show up in feature usage and funnels, and an agent can call one by name.

```ts
import { defineAction } from '@squiggle.sh/react-native';

const addToCart = defineAction('add_to_cart', (product: Product) =>
  cart.add(product)
);
```

Call `addToCart(product)` wherever you called the original. Errors are passed through unchanged.

## identify and tag

`identify` sets who is using the app, for the users and retention views. `tag` adds key-value data to whatever the user is doing right now. Add `appVersion` (or `release`) to the traits to get release markers on the trend charts and to compare releases with [`squiggle product state --drift`](https://squiggle.sh/docs/product#see-what-changed-between-releases).

```ts
import { identify, tag } from '@squiggle.sh/react-native';

identify('user_8f2a', { plan: 'pro', appVersion: '1.4.2' });
tag({ experiment: 'checkout-v2', variant: 'B' });
```

Traits and tags must be plain data under 8 KB. Larger ones are dropped, with a warning in development.

## agentHint

Says what a control is, in props it spreads onto the component. The most useful one is `destructive`: an agent can’t press a destructive control without asking you first.

```ts
import { agentHint } from '@squiggle.sh/react-native';

<Pressable {...agentHint({ id: 'delete-account', destructive: true })}>
  <Text>Delete account</Text>
</Pressable>;
```

| Field | What it does |
| --- | --- |
| `id` | Becomes the testID, so flows can find the control by it. |
| `role` | button, link, tab, switch, checkbox, radio or menuitem, when the component doesn’t have one. |
| `label` | The accessibility label. |
| `destructive` | The control logs out, deletes or pays. Agents need your approval to press it. |
| `flow` | The journey the control belongs to, such as checkout. Groups controls by purpose. |

## logEvent

Writes one structured event. A flow can then assert on a field of the event, which keeps working when the wording of a log message changes.

```ts
import { logEvent } from '@squiggle.sh/react-native';

logEvent('order.submitted', { orderId: 'ord_2291', total: 48.5 });
```

For fields on every event, or your own output, use `createRunLogger` and `consoleJsonSink` from the same package.

## Save and restore your app’s state

`squiggle save checkout-3-items` records the screen your app is on and the data behind it. `squiggle load checkout-3-items` puts both back, in under a second, instead of tapping through screens to rebuild them. In a debug build this needs no code: Squiggle captures the navigation state, every Zustand and Legend State store, and every MMKV instance. A release build is captured only when it was built with `EXPO_PUBLIC_SQUIGGLE_STATE_ANALYTICS=1` and its user hasn’t withdrawn consent. In Studio, the device panel’s **Capture** menu saves and loads the same slots; see [Drive the simulator](https://squiggle.sh/docs/studio/device).

- A save holds plain data. Functions, class instances, `Date` values and cycles are left out, and `squiggle save` lists each one it skipped.
- State that lives inside a component, such as `useState`, a scroll position or a running timer, is not saved.
- MMKV data is copied on the simulator itself, so it loads only on the simulator that took it. Loading the save on another simulator, or after you reset the app’s data, restores the screen and the stores, leaves every MMKV instance as it was, and reports the load as failed, naming the MMKV instance. Saving MMKV needs react-native-mmkv 4 or later (`createMMKV`). An encrypted MMKV instance is never copied, and `squiggle save` lists it as left out.
- A save is checked against your app before any of it is restored. If the app has changed since, such as a store with a renamed or added top-level key, a store that no longer exists, or a screen that was removed, `squiggle load` refuses the whole save, names what changed, and leaves your app as it was. Save again.
- Stores are saved under their names (see [Store names](https://squiggle.sh/docs/reference/sdk#store-names)). A save that names a store by its creation order, such as `store:zustand-2`, is refused once the Babel plugin names that store. Save again.

Saves live in `.squiggle/agent/fixtures/` in your project and never leave your machine.

## Store names

Saves and state samples name each store. With the [Babel plugin](https://squiggle.sh/docs/setup), a store declared at the top level of a module is named after its variable: `export const useCart = create(…)` is `zustand:useCart`, and a Legend State store is `legend:` and its variable. The name stays the same from release to release and across a Fast Refresh.

- A store the plugin can’t name, such as one created inside a component or in a build without the plugin, is named by the order it was created in, such as `store:zustand-3`. That name moves when you add a store.
- Two stores declared under the same variable name in different files share it: the second one created is `useCart~2`.
- After you upgrade Squiggle, restart Metro with `--clear`. Until the bundle is rebuilt, stores keep their `store:zustand-3` names.

## Turn on state samples

State samples feed `squiggle product state`, `--where` and `friction --state`. Your app sends them only when you build it with `EXPO_PUBLIC_SQUIGGLE_STATE_ANALYTICS=1` (or `true`), in a debug build as much as a release one. Saves don’t need it in a debug build.

The value is read when the bundle is built. After you change it, clear the bundler’s cache for the next release build, for example `npx expo export --clear`: without it, the build can keep the value it was built with before.

With the variable set, also tell Squiggle what each of your users chose. The default is allowed, and Squiggle stops capturing the moment you pass `false`:

```ts
import { setStateAnalyticsConsent } from '@squiggle.sh/react-native';

// Call it wherever your app records the user's analytics choice.
setStateAnalyticsConsent(settings.analyticsAllowed);
```

| Consent | What it means |
| --- | --- |
| `off` | The app was built without the variable. Nothing is sampled, whatever you pass. |
| `granted` | Samples are sent. The default with the variable set. |
| `revoked` | You passed false. Nothing is sampled until you pass true, and a release build stops capturing saves too. |

`setStateAnalyticsConsent` returns the consent now in force, `getStateAnalyticsConsent()` reads it, and `onStateAnalyticsConsent(listener)` calls you when it changes and returns a function that stops it.

With consent, Squiggle sends a sample of your app’s state when the screen changes, and half a second after your stores stop changing, or 2 seconds after they started if they never stop. It sends at most two a second, and only when something changed. A sample holds shapes and counts, never values: the current route, how many items an array holds, how many keys an object has, whether a boolean is true, a number’s sign and order of magnitude, and a string’s length rounded down to a power of two. It reads two levels into each store.

- A field whose name looks like a secret, such as `password`, `accessToken`, `sessionId` or `pin`, is left out entirely.
- An object keyed by data, such as emails or ids, is sent as its number of keys only.
- MMKV is sent as each instance’s number of keys.

Each value becomes a path, such as `zustand:useCart.items#len`. See [State paths](https://squiggle.sh/docs/product#state-paths) for every kind.

To send the values of a field such as a theme or a plan tier, list it by its store name. Each call replaces the list:

```ts
import { setStateAnalyticsAllow } from '@squiggle.sh/react-native';

// export const useSettings = create(() => ({ theme: 'dark', plan: 'pro' }));
setStateAnalyticsAllow(['zustand:useSettings.theme', 'zustand:useSettings.plan']);
```

A listed field stops sending values for the rest of the session once it has shown more than 16 different ones, and a value longer than 64 characters is never sent; in both cases its length is sent instead.

## registerFixtureSource

Adds a store Squiggle can’t see on its own to every save, or replaces how one is saved: a source you register wins over Squiggle’s own source of the same name. It returns a function that unregisters the source.

```ts
import { registerFixtureSource } from '@squiggle.sh/react-native';

registerFixtureSource('cart', {
  capture: () => cartStore.getState().items,
  restore: (items) => cartStore.setState({ items }),
  misfit: (items) =>
    Array.isArray(items) ? undefined : 'the cart is no longer a list',
});
```

`misfit` is optional. Return a reason when a save no longer fits your store, for example one taken before you changed its shape, and `undefined` when it does. Squiggle asks every source before restoring any, so one misfit refuses the whole save. Keep the name stable: a save that names a source your app no longer registers is refused the same way.

| Field | What it does |
| --- | --- |
| `capture` | Returns the store’s data as plain data, or a promise of it. Return undefined to leave this source out of this save. |
| `restore` | Puts saved data back. Return false or throw to refuse it; the load reports that source as failed. |
| `misfit` | Optional. Why saved data no longer fits, or undefined when it does. |
| `skipped` | Optional. The paths the last capture left out. squiggle save lists each one as name:path. |

## defineTunable

Names a number an agent may change while the app runs, such as a page size or a debounce, and the range it may take. An agent lists and sets it with the `tune` tool, so a value can be chosen by measuring it instead of by feel. `useTunable` reads it in a component and re-renders when it changes; `get()` reads it anywhere else.

```ts
import { defineTunable, useTunable } from '@squiggle.sh/react-native';

const pageSize = defineTunable('feed.pageSize', {
  default: 20,
  min: 5,
  max: 100,
  step: 5,
  description: 'rows fetched per page',
});

function Feed() {
  const n = useTunable(pageSize);
  // …
}
```

`defineTunable` throws when `step` isn’t positive, when `min` is above `max`, or when the default is outside the range or off the grid. The app refuses any name it didn’t declare, and any value that isn’t a finite number, is outside the range or is off the `step` grid, and the agent sees why, such as `tune: 'feed.pageSize': 7 is not on the step-5 grid from 5`. A value an agent sets is dropped on reload. A Fast Refresh that declares the tunable again keeps the value only while the new range allows it. Nothing else in your app can be changed this way; see [Safety and approvals](https://squiggle.sh/docs/agents/safety).

`tokens` lists the values your design system already uses for the number, such as your spacing scale. A layout repair ([`--repair`](https://squiggle.sh/docs/run/settings#repair-what-a-setup-breaks)) moves each value it chooses onto the nearest token that still works, and flags one it can’t. Each token must be a value the tunable allows, or `defineTunable` throws.

## Environment variable

| Variable | What it does |
| --- | --- |
| `EXPO_PUBLIC_SQUIGGLE_WS_URL=ws://localhost:<port>` | Where the app connects to Studio or the CLI. Unset, an iOS simulator app connects to ws://localhost:7777. Set it when you move the port (squiggle uses the same port from port in config.jsonc or SQUIGGLE_PORT), or to your Mac’s address for a physical device. |
| `EXPO_PUBLIC_SQUIGGLE_WS_URL=off` | Stop the app connecting to Studio or the CLI on its own, so the on-device overlay can run. Studio and squiggle flow still connect when they launch the app. |
| `EXPO_PUBLIC_SQUIGGLE_STATE_ANALYTICS=1` | Send state samples, in any build, subject to each user's choice, and allow saves in a release build. Debug builds capture state for saves either way. |

## Libraries Squiggle instruments

If your app uses one of these, Squiggle records it automatically. You never need to install one for Squiggle.

| Library | What Squiggle records |
| --- | --- |
| `zustand (4+)` | Store changes. |
| `@react-navigation/native` | The current screen. |
| `@tanstack/react-query (5+)` | Query state. |
| `react-native-gesture-handler (2+)` | Gestures. A pan, pinch or rotation also keeps its touch path (up to 32 points), and a pan says what it looked like: a swipe to dismiss, a pull to refresh, a drag to reorder or a scrub. |
| `react-native-worklets (0.8+)` | Gestures that run on the UI thread, with their touch path kept and scored there rather than sent to JavaScript on every move. |
| `react-native-mmkv (2+)` | Reads and writes. Needs the Babel plugin. Saves need 4+. |
| `@react-native-async-storage/async-storage` | Reads and writes. Needs the Babel plugin. |
| `op-sqlite, expo-sqlite` | Queries. Needs the Babel plugin. |
| `@legendapp/state (3+)` | Observable reads and writes. Needs the Babel plugin. |

## Probes in a development build

A development build also runs probes: rules you write in `squiggle probe when` that the app checks while you use it, with no rebuild. There is nothing to set up, and a release build never runs one. See [Watch for a condition inside the app](https://squiggle.sh/docs/performance/profiling#watch-for-a-condition-inside-the-app).

> **MMKV hooks without an instance**
>
> The MMKV value hooks, such as `useMMKVString`, use MMKV’s own default instance when you don’t pass one, and Squiggle can’t see that instance. Pass an instance you created with `createMMKV()`, for example `useMMKVString('theme', storage)`.
