Userbubble
SdksReact native

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.

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

On this page