Examples
Complete code examples for common use cases
Embedded WebView
The recommended integration pattern. Use getEmbedUrl() to get an authenticated URL and display it in a WebView. The embed view includes tab navigation (Feedback, Roadmap, Updates) and user info — you just render a WebView.
Form Sheet / Modal (Recommended)
The best mobile UX — open the portal as a form sheet that slides up from the bottom. This is how most iOS/Android apps present secondary screens.
Expo Router setup:
// app/_layout.tsx
import { Stack } from "expo-router";
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index" />
<Stack.Screen
name="feedback"
options={{
presentation: "formSheet",
headerShown: false,
sheetGrabberVisible: true,
sheetCornerRadius: 16,
}}
/>
</Stack>
);
}// app/feedback.tsx
import { useUserbubble } from "@userbubble/react-native";
import { Stack } from "expo-router";
import { ActivityIndicator, View } from "react-native";
import { WebView } from "react-native-webview";
function LoadingIndicator() {
return (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
);
}
export default function FeedbackModal() {
const { getEmbedUrl, isIdentified } = useUserbubble();
const url = getEmbedUrl("/feedback");
if (!isIdentified || !url) {
return null;
}
return (
<View style={{ flex: 1 }}>
<Stack.Screen
options={{
presentation: "formSheet",
headerShown: false,
}}
/>
<WebView
source={{ uri: url }}
style={{ flex: 1 }}
javaScriptEnabled
domStorageEnabled
startInLoadingState
renderLoading={() => <LoadingIndicator />}
/>
</View>
);
}// app/index.tsx — open it from anywhere
import { router } from "expo-router";
import { Pressable, Text } from "react-native";
function FeedbackButton() {
return (
<Pressable onPress={() => router.push("/feedback")}>
<Text>Give Feedback</Text>
</Pressable>
);
}React Native Modal
If you're not using Expo Router, use a plain Modal component:
import { ActivityIndicator, Modal, SafeAreaView, Pressable, Text, View } from "react-native";
import { useUserbubble } from "@userbubble/react-native";
import { WebView } from "react-native-webview";
import { useState } from "react";
function LoadingIndicator() {
return (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
);
}
function FeedbackModal() {
const { getEmbedUrl, isIdentified } = useUserbubble();
const [visible, setVisible] = useState(false);
const url = getEmbedUrl("/feedback");
if (!isIdentified || !url) {
return null;
}
return (
<>
<Pressable onPress={() => setVisible(true)}>
<Text>Give Feedback</Text>
</Pressable>
<Modal
visible={visible}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={() => setVisible(false)}
>
<SafeAreaView style={{ flex: 1 }}>
<WebView
source={{ uri: url }}
style={{ flex: 1, minHeight: 0 }}
javaScriptEnabled
domStorageEnabled
startInLoadingState
renderLoading={() => <LoadingIndicator />}
/>
</SafeAreaView>
</Modal>
</>
);
}Full-Screen WebView
Show the portal as a full-screen view (e.g. as a dedicated tab):
import { ActivityIndicator, View, Text } from "react-native";
import { useUserbubble } from "@userbubble/react-native";
import { WebView } from "react-native-webview";
function LoadingIndicator() {
return (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
);
}
function FeedbackScreen() {
const { getEmbedUrl, isIdentified } = useUserbubble();
const url = getEmbedUrl("/feedback");
if (!isIdentified || !url) {
return <Text>Please log in first</Text>;
}
return (
<WebView
source={{ uri: url }}
style={{ flex: 1 }}
javaScriptEnabled
domStorageEnabled
startInLoadingState
renderLoading={() => <LoadingIndicator />}
/>
);
}In a Tab Screen (Expo Router)
Add the portal as a tab in your Expo Router app:
// app/(tabs)/feedback.tsx
import { ActivityIndicator, View } from "react-native";
import { useUserbubble } from "@userbubble/react-native";
import { WebView } from "react-native-webview";
function LoadingIndicator() {
return (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
);
}
export default function FeedbackTab() {
const { getEmbedUrl, isIdentified } = useUserbubble();
const url = getEmbedUrl("/feedback");
if (!isIdentified || !url) {
return null;
}
return (
<View style={{ flex: 1 }}>
<WebView
source={{ uri: url }}
style={{ flex: 1 }}
javaScriptEnabled
domStorageEnabled
startInLoadingState
renderLoading={() => <LoadingIndicator />}
/>
</View>
);
}The embed view has its own tab bar at the bottom (Feedback, Roadmap, Updates) and user info at the top. You don't need to build native navigation — it's all handled by the web page.
Basic Integration
Expo App
Complete example for Expo projects with form sheet modal:
import { ActivityIndicator, Alert, Modal, Pressable, SafeAreaView, StyleSheet, Text, TextInput, View } from "react-native";
import { UserbubbleProvider, useUserbubble } from "@userbubble/react-native";
import { WebView } from "react-native-webview";
import { useState } from "react";
function LoadingIndicator() {
return (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
);
}
export default function App() {
return (
<UserbubbleProvider
config={{
apiKey: process.env.EXPO_PUBLIC_USERBUBBLE_API_KEY!,
debug: __DEV__,
}}
>
<MainApp />
</UserbubbleProvider>
);
}
function MainApp() {
const { identify, getEmbedUrl, isIdentified, logout, user } = useUserbubble();
const [showFeedback, setShowFeedback] = useState(false);
const url = getEmbedUrl("/feedback");
if (!isIdentified) {
return <IdentifyForm identify={identify} />;
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome, {user?.name || user?.email}</Text>
<Pressable onPress={() => setShowFeedback(true)} style={styles.button}>
<Text style={styles.buttonText}>Give Feedback</Text>
</Pressable>
<Pressable onPress={logout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</Pressable>
{url ? (
<Modal
visible={showFeedback}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={() => setShowFeedback(false)}
>
<SafeAreaView style={{ flex: 1 }}>
<WebView
source={{ uri: url }}
style={{ flex: 1, minHeight: 0 }}
javaScriptEnabled
domStorageEnabled
startInLoadingState
renderLoading={() => <LoadingIndicator />}
/>
</SafeAreaView>
</Modal>
) : null}
</View>
);
}
function IdentifyForm({
identify,
}: {
identify: (user: { id: string; email: string; name?: string }) => Promise<void>;
}) {
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const handleIdentify = async () => {
try {
await identify({
id: "user_123",
email: email || "user@example.com",
name: name || "Test User",
});
} catch (error) {
Alert.alert("Error", `Failed to identify: ${error}`);
}
};
return (
<View style={styles.form}>
<Text style={styles.formTitle}>Sign in to leave feedback</Text>
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
style={styles.input}
/>
<TextInput
placeholder="Name"
value={name}
onChangeText={setName}
style={styles.input}
/>
<Pressable onPress={handleIdentify} style={styles.button}>
<Text style={styles.buttonText}>Continue</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
padding: 24,
gap: 12,
},
title: {
fontSize: 20,
fontWeight: "bold",
textAlign: "center",
marginBottom: 8,
},
button: {
backgroundColor: "#3b82f6",
borderRadius: 8,
padding: 14,
alignItems: "center",
},
buttonText: {
color: "#fff",
fontWeight: "600",
fontSize: 16,
},
logoutButton: {
borderRadius: 8,
padding: 14,
alignItems: "center",
},
logoutText: {
color: "#888",
fontSize: 14,
},
form: {
flex: 1,
justifyContent: "center",
padding: 24,
},
formTitle: {
fontSize: 20,
fontWeight: "bold",
textAlign: "center",
marginBottom: 16,
},
input: {
borderWidth: 1,
borderColor: "#d4d4d4",
borderRadius: 8,
padding: 12,
fontSize: 16,
marginBottom: 12,
},
});Bare React Native App
Complete example for bare React Native projects:
import React, { useState } from "react";
import { ActivityIndicator, Button, Modal, SafeAreaView, StyleSheet, Text, View } from "react-native";
import { UserbubbleProvider, useUserbubble } from "@userbubble/react-native";
import { WebView } from "react-native-webview";
function LoadingIndicator() {
return (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
);
}
export default function App() {
return (
<UserbubbleProvider
config={{
apiKey: process.env.USERBUBBLE_API_KEY!,
debug: __DEV__,
storageType: "async-storage",
}}
>
<SafeAreaView style={styles.container}>
<MainApp />
</SafeAreaView>
</UserbubbleProvider>
);
}
function MainApp() {
const { identify, getEmbedUrl, isIdentified, logout } = useUserbubble();
const [showFeedback, setShowFeedback] = useState(false);
const url = getEmbedUrl("/feedback");
if (!isIdentified) {
return (
<View style={styles.content}>
<Text style={styles.title}>Welcome to Userbubble</Text>
<Button
title="Sign In"
onPress={() =>
identify({
id: "user_456",
email: "jane@example.com",
name: "Jane Smith",
})
}
/>
</View>
);
}
return (
<View style={styles.content}>
<Button title="Give Feedback" onPress={() => setShowFeedback(true)} />
<Button title="Sign Out" onPress={logout} />
{url ? (
<Modal
visible={showFeedback}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={() => setShowFeedback(false)}
>
<WebView
source={{ uri: url }}
style={{ flex: 1 }}
javaScriptEnabled
domStorageEnabled
startInLoadingState
renderLoading={() => <LoadingIndicator />}
/>
</Modal>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
content: { flex: 1, padding: 20, justifyContent: "center", gap: 12 },
title: { fontSize: 24, fontWeight: "600", marginBottom: 8, textAlign: "center" },
});Integration with Navigation
React Navigation Example
Integrate with React Navigation to identify users after login:
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { UserbubbleProvider, useUserbubble } from "@userbubble/react-native";
import { WebView } from "react-native-webview";
const Stack = createNativeStackNavigator();
export default function App() {
return (
<UserbubbleProvider
config={{
apiKey: process.env.EXPO_PUBLIC_USERBUBBLE_API_KEY!,
}}
>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="Feedback"
component={FeedbackScreen}
options={{
presentation: "formSheet",
headerShown: false,
}}
/>
</Stack.Navigator>
</NavigationContainer>
</UserbubbleProvider>
);
}
function LoginScreen({ navigation }) {
const { identify } = useUserbubble();
const handleLogin = async () => {
try {
const user = await authenticateUser();
await identify({
id: user.id,
email: user.email,
name: user.name,
});
navigation.replace("Home");
} catch (error) {
Alert.alert("Login Failed", error.message);
}
};
return (
<View style={{ flex: 1, justifyContent: "center", padding: 20 }}>
<Button title="Login" onPress={handleLogin} />
</View>
);
}
function HomeScreen({ navigation }) {
const { user, logout } = useUserbubble();
return (
<View style={{ flex: 1, justifyContent: "center", padding: 20, gap: 12 }}>
<Text>Welcome, {user?.name}!</Text>
<Button title="Give Feedback" onPress={() => navigation.push("Feedback")} />
<Button title="Logout" onPress={logout} />
</View>
);
}
function FeedbackScreen() {
const { getEmbedUrl } = useUserbubble();
const url = getEmbedUrl("/feedback");
if (!url) {
return null;
}
return (
<WebView
source={{ uri: url }}
style={{ flex: 1 }}
javaScriptEnabled
domStorageEnabled
startInLoadingState
renderLoading={() => (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
)}
/>
);
}Custom Storage Adapter
Using MMKV Storage
Implement a custom storage adapter for special requirements:
import { UserbubbleProvider, type StorageAdapter } from "@userbubble/react-native";
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
const mmkvStorage: StorageAdapter = {
async getItem(key: string): Promise<string | null> {
return storage.getString(key) ?? null;
},
async setItem(key: string, value: string): Promise<void> {
storage.set(key, value);
},
async removeItem(key: string): Promise<void> {
storage.delete(key);
},
async clear(): Promise<void> {
storage.clearAll();
},
};
export default function App() {
return (
<UserbubbleProvider
config={{
apiKey: process.env.EXPO_PUBLIC_USERBUBBLE_API_KEY!,
customStorage: mmkvStorage,
}}
>
<YourApp />
</UserbubbleProvider>
);
}Advanced Patterns
Conditional Rendering Based on Auth State
import { useUserbubble } from "@userbubble/react-native";
function MyScreen() {
const { isInitialized, isIdentified, user } = useUserbubble();
if (!isInitialized) {
return <LoadingScreen />;
}
if (!isIdentified) {
return <LoginScreen />;
}
return (
<View>
<Text>Welcome, {user?.name}!</Text>
<YourAppContent />
</View>
);
}Reusable Feedback Button
Opens Userbubble in the device browser:
import { useUserbubble } from "@userbubble/react-native";
import { Button, type ButtonProps } from "react-native";
interface FeedbackButtonProps extends Omit<ButtonProps, "onPress"> {
path?: string;
}
export function FeedbackButton({ path = "/feedback", ...props }: FeedbackButtonProps) {
const { openUserbubble, isIdentified } = useUserbubble();
if (!isIdentified) {
return null;
}
return (
<Button
{...props}
title={props.title || "Give Feedback"}
onPress={() => openUserbubble(path)}
/>
);
}Testing
Mocking for Tests
Mock Userbubble for unit tests:
// __mocks__/@userbubble/react-native.ts
export const useUserbubble = jest.fn(() => ({
user: null,
isInitialized: true,
isIdentified: false,
authToken: null,
organizationSlug: null,
identify: jest.fn(),
logout: jest.fn(),
getUser: jest.fn(() => null),
openUserbubble: jest.fn(),
getEmbedUrl: jest.fn(() => null),
}));
export const UserbubbleProvider = ({ children }: { children: React.ReactNode }) => (
<>{children}</>
);Test example:
import { render, fireEvent } from "@testing-library/react-native";
import { useUserbubble } from "@userbubble/react-native";
jest.mock("@userbubble/react-native");
describe("MyComponent", () => {
it("identifies user on login", async () => {
const mockIdentify = jest.fn();
(useUserbubble as jest.Mock).mockReturnValue({
identify: mockIdentify,
isIdentified: false,
getEmbedUrl: jest.fn(() => null),
});
const { getByText } = render(<MyComponent />);
fireEvent.press(getByText("Login"));
expect(mockIdentify).toHaveBeenCalledWith({
id: "user_123",
email: "test@example.com",
});
});
});Next Steps
- Review the API Reference for complete documentation
- Return to Quick Start guide
- Check Installation requirements