React Native and Expo
React Native is a first-class runtime. The core runs on Hermes, the React binding is the React Native binding, and the providers whose SDK runs its web API on React Native are served by their web adapter. Two SDKs have a React Native API of their own, and each has its own package whose factory keeps the provider's name.
| SDK on React Native | Package |
|---|---|
React Native Firebase, @react-native-firebase/auth | @priemskiyyy/anchor-firebase-react-native |
| react-native-auth0 | @priemskiyyy/anchor-auth0-react-native |
The Firebase JS SDK, firebase | @priemskiyyy/anchor-firebase |
| supabase-js | @priemskiyyy/anchor-supabase |
Better Auth with @better-auth/expo | @priemskiyyy/anchor-better-auth |
Clerk Expo, through getClerkInstance() | @priemskiyyy/anchor-clerk |
Amplify v6 with @aws-amplify/react-native | @priemskiyyy/anchor-amplify |
| Your own backend | @priemskiyyy/anchor-http |
The adapters for auth0-spa-js, MSAL.js, oidc-client-ts, keycloak-js, okta-auth-js, AuthKit and next-auth name browsers as their runtime.
Set it up
import { Anchor } from "@priemskiyyy/anchor";
import { firebase, idToken } from "@priemskiyyy/anchor-firebase-react-native";
import { appState } from "@priemskiyyy/anchor-react-native";
import { getAuth } from "@react-native-firebase/auth";
import { AppState } from "react-native";
export const anchor = new Anchor({
adapter: firebase({ sdk: getAuth(), tokens: { api: idToken() } }),
hints: [appState({ sdk: AppState })],
});
anchor.start().catch((error: unknown) => {
console.warn("The session adapter could not open", error);
});Publish it with AnchorProvider from @priemskiyyy/anchor-react and read it with useSession(), exactly as on the web: useSyncExternalStore works on React Native unchanged, and nothing in the binding touches the DOM.
import { AnchorProvider, useSession } from "@priemskiyyy/anchor-react";
import { Text } from "react-native";
const Account = () => {
const name = useSession((state) =>
state.status === "signed-in" ? state.session.user.displayName : null,
);
return <Text>{name ?? "Welcome"}</Text>;
};
export const Root = () => (
<AnchorProvider anchor={anchor}>
<Account />
</AnchorProvider>
);Read again in the foreground
@priemskiyyy/anchor-react-native ships one hint, appState({ sdk: AppState }). Pass React Native's AppState; the hint never imports react-native itself.
pnpm add @priemskiyyy/anchor-react-native- A transition to
activefrombackground,inactiveor an unknown state reads again. Going to the background or inactive reads nothing, and a repeatedactivereport is not a return. - An app started in the background reads again the first time it comes to the foreground.
- A quick background and foreground costs one read and one queued read at most, because reads go through Anchor's own read path.
Foregrounding is a reason to read, never proof that a session is valid, and nothing refreshes in the background: the provider SDK refreshes its own tokens. It works the same on Expo and in bare React Native.
Pull to refresh
A pull to refresh is anchor.sync():
import { useAnchor } from "@priemskiyyy/anchor-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { RefreshControl, ScrollView } from "react-native";
export const RefreshableScreen = ({ children }: { children: ReactNode }) => {
const anchor = useAnchor();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = () => {
setRefreshing(true);
anchor
.sync()
.catch((error: unknown) => {
console.warn("The session could not be read", error);
})
.finally(() => {
setRefreshing(false);
});
};
return (
<ScrollView
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
>
{children}
</ScrollView>
);
};A sync() that fails leaves the state as it was: a failed read is never a sign-out.
Persistence stays with the SDK
Secure storage and restoring a session after a restart are the SDK's. Each adapter's first read waits for its SDK's restoration, so a slow restore is initializing, never signed-out:
- React Native Firebase restores its user natively; the first read awaits
authStateReady(), which settles once the native SDK reported its first auth state. - The Firebase JS SDK restores from the persistence you configure; the first read awaits
authStateReady(). - supabase-js restores its stored session itself; the first state comes from
auth.getSession(). supabase-js'sstartAutoRefreshandstopAutoRefresharoundAppStatechanges stay in your application. - Better Auth with the Expo plugin keeps the session cookie in the storage you pass, such as
expo-secure-store. Every read is the session atom's own fetch, so a start without a network answers the session the plugin cached, and an ending without a network still clears the session on the device. The plugin refetches when the app returns to the foreground, soappState()is optional there. - Clerk Expo loads its instance headless; Anchor is
initializinguntil<ClerkProvider>has loaded it. - Amplify keeps its tokens in AsyncStorage and the first read waits for them. Amplify dispatches
signedOutwithout waiting for AsyncStorage to finish removing the tokens, so the adapter pushessigned-outon that event instead of reading. With the hosted UI, a user who closes the logout page leavessession.end()answeringlocal: "unknown", and the session stays. - react-native-auth0 keeps the login in its credentials manager, and every call waits for its native module to initialize. It emits no events, so call
anchor.sync()aftersaveCredentials()orclearCredentials().
Hermes
- A session key is
crypto.randomUUID()where it exists and a random version 4 UUID where it does not, as on Hermes. - Anchor never calls
signal.throwIfAborted(), the staticAbortSignalhelpers orsignal.reason, which React Native'sAbortControllerpolyfill lacks; the library's ESLint configuration refuses them. One caller's aborted token request rejects withABORTEDand no cause on every platform for the same reason. - A core test runs the lifecycle under a React Native style
AbortSignaland withoutcrypto: a session starts, mints, aborts one caller and disposes, and a read that outlives the timeout makes the first stateunavailable. - Every package React Native can use pairs
importwithdefaultin itsexports, so Jest resolves it.
Expo
Anchor's packages are plain JavaScript and need no config plugin. Your provider SDK may: React Native Firebase and react-native-auth0 need their native modules, so they run in a development build or a bare app, never in Expo Go. Better Auth needs expoClient from @better-auth/expo/client on the client and the expo() plugin with your app's scheme on the server; its page shows both.
What was verified
Nothing here has run on a device or a simulator. The React Native adapters run against fakes typechecked against the real SDKs' declarations, and against the real SDKs' JavaScript in Node where it runs there: react-native-auth0's over a replaced native module that answers with the codes its iOS bridge gives, React Native Firebase's over the module it registers for platforms without the native SDKs, and Better Auth's Expo plugin against a real server with only the native modules replaced. The appState() hint's contract is typechecked against React Native 0.86.3's own AppState. See the verification matrix.