Skip to content

Expo and React Native SDK

@vaifstudio/expo is the React SDK’s provider and hooks for React Native and Expo. It keeps the session in the storage you give it, refreshes it automatically, and closes the realtime connection while the app is in the background.

Terminal window
npx expo install @vaifstudio/expo @vaifstudio/client @react-native-async-storage/async-storage

It needs React Native 0.70 or later (Expo SDK 47 or later). @react-native-async-storage/async-storage is optional: without it, the session lasts only while the app runs.

import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { VaifProvider } from "@vaifstudio/expo";
export function Providers({ children }: { children: ReactNode }) {
return (
<VaifProvider
config={{
projectId: process.env.EXPO_PUBLIC_VAIF_PROJECT_ID!,
apiKey: process.env.EXPO_PUBLIC_VAIF_PUBLISHABLE_KEY!, // the publishable vk_pub_ key
}}
storage={AsyncStorage}
>
{children}
</VaifProvider>
);
}

Pass client={createVaifClient(...)} instead of config to share a client you created. Inside the tree, useVaifClient() returns it.

import { useState } from "react";
import { Button, Text, TextInput, View } from "react-native";
import { isVaifError } from "@vaifstudio/client";
import { useAuth, useMFA } from "@vaifstudio/expo";
export function SignInScreen() {
const { user, isLoading, signIn, signOut } = useAuth();
const { verify } = useMFA();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [challenge, setChallenge] = useState<string | null>(null);
if (isLoading) return <Text>Loading…</Text>;
if (user) return <Button title={`Sign out ${user.email}`} onPress={() => signOut()} />;
if (challenge) {
return (
<View>
<TextInput value={code} onChangeText={setCode} keyboardType="number-pad" />
<Button title="Verify" onPress={() => verify(challenge, code)} />
</View>
);
}
const submit = async () => {
try {
await signIn(email, password);
} catch (e) {
if (isVaifError(e) && e.code === "MFA_REQUIRED") {
setChallenge((e.details as { challengeToken: string }).challengeToken);
}
}
};
return (
<View>
<TextInput value={email} onChangeText={setEmail} autoCapitalize="none" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry />
<Button title="Sign in" onPress={submit} />
</View>
);
}

The provider keeps the session, including the refresh token, in the storage you pass (key vaif-auth) and checks it with the API on the next launch. It refreshes the access token once, a minute before it expires, and signs the user out if the refresh fails.

Other auth hooks: useUser, useToken, usePasswordReset, useEmailVerification, useMagicLink (send a link that opens your app, then verifyMagicLink(accessToken) with the token from the link) and useMFA. Google and GitHub sign-in for your app’s users is not available in the Expo SDK yet.

import { FlatList, Text } from "react-native";
import { useQuery, useCreate } from "@vaifstudio/expo";
type Note = { id: string; title: string; done: boolean };
export function Notes() {
const { data, isLoading, refetch } = useQuery<Note>("notes", {
where: { field: "done", operator: "eq", value: false },
limit: 50,
});
const { create } = useCreate<Note>("notes");
const duplicate = async (note: Note) => {
await create({ title: "Copy of " + note.title, done: false });
await refetch();
};
if (isLoading) return <Text>Loading…</Text>;
return (
<FlatList
data={data}
keyExtractor={(n) => n.id}
renderItem={({ item }) => <Text onPress={() => duplicate(item)}>{item.title}</Text>}
/>
);
}

The hooks share the client’s one connection and close it while the app is in the background. Subscriptions are kept and resume when the app returns. Pass pauseInBackground: false to keep a hook’s connection open.

import { useState } from "react";
import { Text } from "react-native";
import { useRealtime } from "@vaifstudio/expo";
type Todo = { id: string; list_id: string; title: string };
export function LiveCount({ listId }: { listId: string }) {
const [count, setCount] = useState(0);
const { status } = useRealtime<Todo>("todos", {
filter: { field: "list_id", value: listId },
onInsert: () => setCount((c) => c + 1),
});
return <Text>{count} new {status === "SUBSCRIBED" ? "(live)" : ""}</Text>;
}

Also available: useSubscription, useChannel, useBroadcast, usePresence, useRealtimeConnection and useAppState.

import { Button } from "react-native";
import { useUpload, useFunction } from "@vaifstudio/expo";
export function UploadPhoto({ uri }: { uri: string }) {
const { uploadUri, isUploading } = useUpload("photos");
const { invoke } = useFunction<{ ok: boolean }, { key: string }>("make-thumbnail");
const go = async () => {
const result = await uploadUri(uri, `me/${Date.now()}.jpg`, { contentType: "image/jpeg" });
if (result) await invoke({ key: result.key });
};
return <Button title={isUploading ? "Uploading…" : "Upload"} onPress={go} disabled={isUploading} />;
}

uploadUri reads a local file URI, such as a photo from the camera roll, and uploads it.