# React Native

> Repositories in Expo and React Native apps, with sessions in the device keychain.

Source: https://bettersupabase.com/docs/frontend/react-native

```ts title="src/lib/supabase/native.ts"
import { createClient } from "@supabase/supabase-js";
import {
  autoRefreshOnForeground,
  createNativeClient,
  secureStorage,
} from "better-supabase/client/native";
import * as SecureStore from "expo-secure-store";
import { AppState } from "react-native";
import { betterSupabase } from "./index";

const supabase = createClient(
  process.env.EXPO_PUBLIC_SUPABASE_URL,
  process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
  {
    auth: {
      storage: secureStorage(SecureStore),
      autoRefreshToken: true,
      persistSession: true,
      detectSessionInUrl: false,
    },
  },
);

autoRefreshOnForeground(supabase, AppState);

export const bs = createNativeClient(betterSupabase, supabase);
```

`better-supabase/client/native` is the [browser client](/docs/frontend/client)
without the cookie code: it never imports `@supabase/ssr`, so Metro doesn't
bundle it into the app. `bs.db`, `bs.queries`, `bs.auth` and `bs.supabase`
work as they do in the browser, and the [React hooks](/docs/frontend/react)
take the same `bs`.

`better-supabase init expo` writes this file, a server file for
[Expo Router](/docs/frameworks/expo) and `+middleware.ts`.

## Sessions in the keychain [#sessions-in-the-keychain]

`secureStorage(SecureStore)` stores the Supabase session with
`expo-secure-store`. A session with custom claims is often larger than the
2048 bytes iOS accepts for one value, so it is split into chunks of 1800
characters (`chunkSize` changes that). Keys are rewritten to the characters
SecureStore allows. Any object with `getItemAsync`, `setItemAsync` and
`deleteItemAsync` works in its place.

Reads and writes of one key run one at a time. A write stores the new
chunks next to the old ones and switches `<key>.chunks` to them only once
all are stored, so a read never joins chunks of two sessions, and a write
that fails part way keeps the previous session.

## Refreshing in the foreground [#refreshing-in-the-foreground]

supabase-js refreshes the access token on a timer, but on React Native it
can't tell when the app goes to the background. Supabase asks apps to call
`supabase.auth.startAutoRefresh()` when the app becomes active and
`stopAutoRefresh()` when it leaves. `autoRefreshOnForeground(supabase, AppState)`
does both: it starts refreshing at once when `AppState.currentState` is
`active`, follows every `change` event after that, and returns a function
that removes the listener and stops refreshing.

```tsx title="src/app/_layout.tsx"
import { useEffect } from "react";
import { AppState } from "react-native";
import { autoRefreshOnForeground } from "better-supabase/client/native";
import { supabase } from "@/lib/supabase/native";

export default function RootLayout() {
  useEffect(() => autoRefreshOnForeground(supabase, AppState), []);
  // ...
}
```

Call it once, either at module level next to `createClient` as above or in
an effect as here, not both. `AppState` is typed by its shape, so
better-supabase never imports `react-native`; any object with `currentState`
and `addEventListener("change", listener)` works, including a fake in a unit
test.

## Large sessions [#large-sessions]

`largeSecureStorage` keeps a session of any size without chunking: it
encrypts the session with AES-GCM, stores the result in MMKV or AsyncStorage
and keeps only the 256-bit key in the keychain. Every write uses a fresh key.
When the key and the data come from different installs (a reinstall kept one
of them), the read returns `null`, so the user signs in again instead of
seeing an error.

```ts title="src/lib/supabase/native.ts"
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import { largeSecureStorage } from "better-supabase/client/native";

const storage = largeSecureStorage({
  secureStore: SecureStore,
  storage: AsyncStorage,
});
```

It uses the global `crypto`. On Hermes builds without WebCrypto, pass
`crypto` from `react-native-quick-crypto`.

## TanStack Query on a device [#tanstack-query-on-a-device]

`syncQueryWithApp` tells TanStack Query when the app is in the foreground and
when the device is online, so queries refetch when the user returns and
pause without a connection. With `supabase`, it also calls
`autoRefreshOnForeground`, so one call covers both.

```tsx title="src/app/_layout.tsx"
import NetInfo from "@react-native-community/netinfo";
import { focusManager, onlineManager } from "@tanstack/react-query";
import { syncQueryWithApp } from "better-supabase/client/native";
import { AppState } from "react-native";

useEffect(
  () =>
    syncQueryWithApp({
      focusManager,
      onlineManager,
      appState: AppState,
      netInfo: NetInfo,
      supabase,
    }),
  [],
);
```

`persistQueryCache(storage)` is a TanStack Query persister over MMKV or
AsyncStorage. A cold start shows the cached rows before the first fetch, and
writes are throttled to one per second. The provider's `clearOnUserChange`
drops the cache when another user signs in.

```tsx
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: persistQueryCache(storage) }}>
```

## Uploading picked files [#uploading-picked-files]

React Native can't upload a `Blob`. `uploadFromUri` reads a `file://` or
`content://` URI from `expo-image-picker` or `expo-document-picker` into an
`ArrayBuffer` and uploads it to a [typed bucket](/docs/platform/storage). The
content type comes from the `contentType` option, then the file extension,
then the response header. A file that can't be read returns an
`invalid_input` error.

```ts
const picked = await ImagePicker.launchImageLibraryAsync();
const uri = picked.assets?.[0]?.uri;
if (uri) await uploadFromUri(avatars, { userId }, uri, { upsert: true });
```

## Sign-in on a device [#sign-in-on-a-device]

`better-supabase/react/native` has hooks for OAuth, deep links and protected
routes. They read the client from `<BetterSupabaseProvider>`, or take
`{ client: supabase }`.

```tsx title="src/app/(auth)/sign-in.tsx"
import * as Linking from "expo-linking";
import * as WebBrowser from "expo-web-browser";
import { useOAuth } from "better-supabase/react/native";

export default function SignIn() {
  const oauth = useOAuth({
    browser: WebBrowser,
    redirectTo: Linking.createURL("auth/callback"),
  });
  return (
    <Button
      title="GitHub"
      disabled={oauth.pending}
      onPress={() => oauth.signIn("github")}
    />
  );
}
```

`oauth.signIn` opens the provider in an auth session and finishes the
sign-in from the redirect; a dismissed browser is not an error.
`oauth.idToken` signs in with an ID token from `expo-apple-authentication` or
Google Sign-In. Add the redirect URL to the Auth redirect allow list.

`useAuthDeepLinks(Linking)` finishes sign-ins from links that open the app:
magic links, email confirmations and PKCE codes. It handles the initial URL
and every link after it, once each, and returns the last outcome.
`handleAuthDeepLink(supabase, url)` and `signInWithOAuthBrowser` in
`better-supabase/client/native` do the same without React.

```tsx title="src/app/_layout.tsx"
import { useRouter, useSegments } from "expo-router";
import {
  useAuthDeepLinks,
  useProtectedRoute,
} from "better-supabase/react/native";

function Navigation() {
  useAuthDeepLinks(Linking);
  const status = useProtectedRoute({
    segments: useSegments(),
    router: useRouter(),
  });
  if (status === "loading") return <Splash />;
  return <Stack />;
}
```

`useProtectedRoute` sends signed-out users outside the `(auth)` group to
`/sign-in`, and signed-in users inside it to `/`; `publicGroup`,
`signInHref` and `homeHref` change those. `<AuthGate fallback signedOut>`
renders by auth status for apps without expo-router. With expo-router's
`Stack.Protected`, pass `useAuth().status === "signed-in"` as its `guard`.

## Hermes and Temporal [#hermes-and-temporal]

Hermes has no `Temporal`, and installing a polyfill on `globalThis` is a side
effect most apps avoid. Pass the namespace to the definition instead:

```ts title="src/lib/supabase/index.ts"
import { defineSupabase } from "better-supabase";
import { Temporal } from "temporal-polyfill";
import { schema } from "./generated";

export const betterSupabase = defineSupabase(schema, { temporal: Temporal });
```

With `codecs: { timestamptz: "instant" }`, rows then decode `timestamptz`
columns to that namespace's `Temporal.Instant` and `timestamp` columns to its
`Temporal.PlainDateTime`; `date` columns stay strings. Every helper that reads
the time uses the namespace too. The generated Valibot and Zod schemas
check Temporal values by their tag, so they load without a global. See
[Temporal](/docs/concepts/temporal) for the types each column gets.

## Web and native in one app [#web-and-native-in-one-app]

Metro picks `file.native.ts` on iOS and Android and `file.ts` on the web. Keep
anything that only works on a device (SecureStore, PowerSync) in `.native`
files or in modules only they import, so the web bundle never loads it. The
[Expo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/expo-powersync)
renders a list from a server loader on the web and from the PowerSync
database on the device, with one [list definition](/docs/platform/list).

## Offline reads [#offline-reads]

To read while offline, sync the tables to the device with PowerSync and run
the same repositories on its SQLite database. See
[PowerSync](/docs/repository/powersync) for the executor and
[Offline-first](/docs/guides/offline-first) for uploading local changes.