Skip to content

React Native and Expo

Use @xmtp/react-native-sdk@8.0.0 for a React Native app connected to your self-hosted XMTP backend. It exposes the native v8 TypeScript API through a Hermes JSI TurboModule. Import app APIs from the package root. The raw generated bindings are private.

Enable Hermes and React Native New Architecture. React Native 0.88.0 requires Node ^22.13.0 || ^24.3.0 || >=26.0.0 for app tooling. The package contains prebuilt Rust libraries. App builds need the normal React Native Android or iOS tools; app developers do not need Rust or Nix.

PlatformDeployment floorNative targets
Bare React Native, iOSiOS 15.1arm64 device and arm64 simulator
Expo development build, iOSiOS 16.4arm64 device and arm64 simulator
AndroidAPI 24arm64-v8a, armeabi-v7a, x86, x86_64

Intel iOS simulators, React Native Web, desktop React Native, and Expo Go cannot load this package. Package peer dependency ranges are not a tested support matrix.

Bare React Native 0.88.0 with React 19.3.0 and Expo SDK 58.0.14 with React Native 0.88.0 and React 19.3.0 have passed installed-package builds and Hermes runtime checks on both arm64 iOS simulators and Android emulators. These checks do not cover physical devices, APNs delivery, or system-triggered cold process wake.

Older candidate versions require separate platform checks before being listed as supported. React Native 0.79.7, 0.80.3, 0.81.6, 0.82.1, 0.83.10, 0.84.1, 0.85.3, 0.86.3, and 0.87.1 currently have skipped validation on both platforms and are not advertised as supported.

Add the package to a native React Native app with Hermes and New Architecture:

Terminal window
npm install --save-exact @xmtp/react-native-sdk@8.0.0

For iOS, install the app’s pods after adding the package:

Terminal window
cd ios
bundle exec pod install

Build the native app with its normal Xcode or Gradle workflow. Autolinking adds the XMTP native module. Reloading Metro alone does not install native code. Rebuild after installing or changing the SDK version.

Use an Expo development build or a prebuilt native project. Expo Go does not include the XMTP native module.

Terminal window
npx expo install @xmtp/react-native-sdk@8.0.0
npx expo prebuild
npx expo run:ios

Use npx expo run:android for Android. Keep the Expo SDK, React Native, and React versions aligned. Rebuild the development client when the native dependency changes. An over-the-air JavaScript update cannot add or replace the native SDK.

Supply a backend URL the device can reach. A phone’s localhost is the phone, not your development computer. Use your deployment’s HTTPS URL, or configure a reachable development backend and the app’s local network policy.

The app supplies a signer and a saved 32-byte database encryption key. This complete TypeScript example connects, receives text, sends to another inbox, and releases its client. Apply consent and your app’s display policy in the incoming-message callback before showing content:

import {
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
, type
(alias) interface Signer
import Signer
Signer
} from "@xmtp/react-native-sdk";
export async function
function connectMessaging(signer: Signer, backendUrl: string, databaseKey: Uint8Array, onText: (text: string) => Promise<void>, onError: (error: unknown) => void): Promise<{
inboxId: string;
send(peerInboxId: string, text: string): Promise<string>;
disconnect(): Promise<void>;
}>
connectMessaging
(
signer: Signer
signer
:
(alias) interface Signer
import Signer
Signer
,
backendUrl: string
backendUrl
: string,
databaseKey: Uint8Array<ArrayBufferLike>
databaseKey
:
interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike>

A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the requested number of bytes could not be allocated an exception is raised.

Uint8Array
,
onText: (text: string) => Promise<void>
onText
: (
text: string
text
: string) =>
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<void>,
onError: (error: unknown) => void
onError
: (
error: unknown
error
: unknown) => void,
) {
const
const client: Client
client
= await
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
.
Client.create(signer: Signer, options: ClientOptions): Promise<Client>
create
(
signer: Signer
signer
, {
backend?: BackendSource | undefined
backend
: {
url: string
url
:
backendUrl: string
backendUrl
},
storage: StorageOptions
storage
: {
location: StorageLocation
location
: "default",
encryptionKey?: Uint8Array<ArrayBufferLike> | undefined
encryptionKey
:
databaseKey: Uint8Array<ArrayBufferLike>
databaseKey
},
});
const
const messages: MessageStream
messages
= await
const client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.streamAllMessages(options?: MessageStreamOptions): MessageStream
streamAllMessages
({
consentStates?: readonly ConsentState[] | undefined
consentStates
: ["allowed"],
});
const
const receiving: Promise<void>
receiving
=
const messages: MessageStream
messages
.
ReaderStream<Message>.onValue(callback: (value: Message) => void | Promise<void>): Promise<void>

Await each callback before the next read. For messages, that read acknowledges the prior message. Await all processing in the callback. Work sent to an app queue or an unawaited task can continue after acknowledgement.

onValue
(async (
message: Message
message
) => {
if (
message: Message
message
.
Message.content: MessageContent
content
.
kind: "unknown" | "text" | "markdown" | "readReceipt" | "reaction" | "attachment" | "remoteAttachment" | "multiRemoteAttachment" | "transactionReference" | "walletSendCalls" | "actions" | "intent" | "groupUpdated" | "leaveRequest" | "deletedMessage" | "reply" | "custom"
kind
=== "text") {
await
onText: (text: string) => Promise<void>
onText
(
message: Message
message
.
Message.content: {
readonly kind: "text";
readonly value: string;
}
content
.
value: string
value
);
}
});
void
const receiving: Promise<void>
receiving
.
Promise<void>.catch<void>(onrejected?: ((reason: any) => void | PromiseLike<void>) | null | undefined): Promise<void>

Attaches a callback for only the rejection of the Promise.

@param ― onrejected The callback to execute when the Promise is rejected.

@returns ― A Promise for the completion of the callback.

catch
(
onError: (error: unknown) => void
onError
);
return {
inboxId: string
inboxId
:
const client: Client
client
.
ClientMembers.inboxId: string
inboxId
,
async
function send(peerInboxId: string, text: string): Promise<string>
send
(
peerInboxId: string
peerInboxId
: string,
text: string
text
: string) {
const
const dm: Dm
dm
= await
const client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.createDm(peer: InboxId | PublicIdentity, options?: CreateDmOptions): Promise<Dm>
createDm
(
peerInboxId: string
peerInboxId
);
return
const dm: Dm
dm
.
Dm.sendText(text: string, options?: SendOptions): Promise<MessageId>
sendText
(
text: string
text
);
},
async
function disconnect(): Promise<void>
disconnect
() {
try {
await
const messages: MessageStream
messages
.
ReaderStream<Message>.end(): Promise<void>
end
();
await
const receiving: Promise<void>
receiving
;
} finally {
await
const client: Client
client
.
Client.end(): Promise<void>

End the client. Messages from it then fail with ClientClosed. Ending a closed client does nothing, also after its browser worker stopped.

end
();
}
},
};
}

Save the signing key and database key in the app’s secret store. Reuse them and the database location on later launches. Client.generateKeys() creates a local signer; generating a new one on every launch creates a different identity. The SDK does not choose a Keychain or Android Keystore policy for your app. See Signer and Create a client.

storage.location: "default" uses the native app storage directory. Explicit paths must be app-accessible native filesystem paths. Omitting encryptionKey selects unencrypted storage. A wrong key produces a typed storage error. End clients before moving, converting, or deleting database files.

The /react entry exports XmtpProvider, XmtpContext, useXmtp, useClient, and their public v8 types. It uses the same native runtime as the package root. Place one provider around the components that share a client:

export function
function MessagingProvider({ children }: {
children: ReactNode;
}): React.FunctionComponentElement<XmtpProviderProps>
MessagingProvider
({
children: ReactNode
children
}: {
children: ReactNode
children
:
type ReactNode = string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<ReactNode> | React.ReactPortal | Promise<AwaitedReactNode> | null | undefined

Represents all of the things React can render.

Where

ReactElement

only represents JSX, ReactNode represents everything that can be rendered.

@see ― https://react-typescript-cheatsheet.netlify.app/docs/reference/reactnode/ React TypeScript Cheatsheet

@example

// Typing children
type Props = { children: ReactNode }
const Component = ({ children }: Props) => <div>{children}</div>
<Component>hello</Component>

@example

// Typing a custom element
type Props = { customElement: ReactNode }
const Component = ({ customElement }: Props) => <div>{customElement}</div>
<Component customElement={<div>hello</div>} />

ReactNode
}) {
return
createElement<XmtpProviderProps>(type: React.FunctionComponent<XmtpProviderProps>, props?: (React.Attributes & XmtpProviderProps) | null | undefined, ...children: ReactNode[]): React.FunctionComponentElement<XmtpProviderProps> (+6 overloads)
createElement
(
function XmtpProvider({ children, client, onError, }: XmtpProviderProps): React.ReactElement
XmtpProvider
, {
XmtpProviderProps.children: ReactNode
children
});
}

useXmtp() reads the context. useClient(onError?) reads it and can report command errors to a mounted component. Both expose client, error, isLoading, initialize({ signer, options }), and disconnect(). Both hooks must run inside XmtpProvider.

export function
function useMessaging(signer: Signer, backendUrl: string, databaseKey: Uint8Array): {
client: Client | null;
error: Error | null;
isLoading: boolean;
connect: () => Promise<Client>;
disconnect: (this: void) => Promise<void>;
}
useMessaging
(
signer: Signer
signer
:
(alias) interface Signer
import Signer
Signer
,
backendUrl: string
backendUrl
: string,
databaseKey: Uint8Array<ArrayBufferLike>
databaseKey
:
interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike>

A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the requested number of bytes could not be allocated an exception is raised.

Uint8Array
,
) {
const {
const client: Client | null
client
,
const error: Error | null
error
,
const isLoading: boolean
isLoading
,
const initialize: (this: void, options: InitializeClientOptions) => Promise<Client>
initialize
,
const disconnect: (this: void) => Promise<void>
disconnect
} =
function useClient(onError?: (error: Error) => void): XmtpContextValue
useClient
();
return {
client: Client | null
client
,
error: Error | null
error
,
isLoading: boolean
isLoading
,
connect: () => Promise<Client>
connect
: () =>
const initialize: (this: void, options: InitializeClientOptions) => Promise<Client>
initialize
({
InitializeClientOptions.signer: Signer
signer
,
InitializeClientOptions.options: ClientOptions
options
: {
backend?: BackendSource | undefined
backend
: {
url: string
url
:
backendUrl: string
backendUrl
},
storage: StorageOptions
storage
: {
location: StorageLocation
location
: "default",
encryptionKey?: Uint8Array<ArrayBufferLike> | undefined
encryptionKey
:
databaseKey: Uint8Array<ArrayBufferLike>
databaseKey
},
},
}),
disconnect: (this: void) => Promise<void>
disconnect
,
};
}

Call connect from an app action and handle its rejected promise. Calls made while initialization is pending share that operation. Calling initialize while connected returns the current client; disconnect before changing the signer or options. disconnect() cancels pending initialization and releases a client created by the provider. Unmounting also releases a provider-created client. A late initialization result is closed before it can enter context. The pending promise rejects with XmtpError.Cancelled. Cancellation does not abort an external wallet callback or native creation. A new attempt can encounter a normal storage conflict until the old creation settles. If release fails, handle the error and retry disconnect() before initializing again.

For an existing client, pass the instance as a prop:

export function
function ExistingClientProvider({ client, children, }: {
client: Client;
children: ReactNode;
}): React.FunctionComponentElement<XmtpProviderProps>
ExistingClientProvider
({
client: Client
client
,
children: ReactNode
children
,
}: {
client: Client
client
:
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
;
children: ReactNode
children
:
type ReactNode = string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<ReactNode> | React.ReactPortal | Promise<AwaitedReactNode> | null | undefined

Represents all of the things React can render.

Where

ReactElement

only represents JSX, ReactNode represents everything that can be rendered.

@see ― https://react-typescript-cheatsheet.netlify.app/docs/reference/reactnode/ React TypeScript Cheatsheet

@example

// Typing children
type Props = { children: ReactNode }
const Component = ({ children }: Props) => <div>{children}</div>
<Component>hello</Component>

@example

// Typing a custom element
type Props = { customElement: ReactNode }
const Component = ({ customElement }: Props) => <div>{customElement}</div>
<Component customElement={<div>hello</div>} />

ReactNode
;
}) {
return
createElement<XmtpProviderProps>(type: React.FunctionComponent<XmtpProviderProps>, props?: (React.Attributes & XmtpProviderProps) | null | undefined, ...children: ReactNode[]): React.FunctionComponentElement<XmtpProviderProps> (+6 overloads)
createElement
(
function XmtpProvider({ children, client, onError, }: XmtpProviderProps): React.ReactElement
XmtpProvider
, {
XmtpProviderProps.client?: Client | null | undefined
client
,
XmtpProviderProps.children: ReactNode
children
});
}
export function
function useCurrentMessagingClient(): Client | null
useCurrentMessagingClient
() {
return
function useXmtp(): XmtpContextValue
useXmtp
().
XmtpContextValue.client: Client | null
client
;
}

The caller owns this client and must call end() when finished with it. A prop change updates context. Passing a provider-created client back as a prop does not change its ownership. The helpers do not export legacy generic client types, setClient, message hooks, or conversation hooks. Use the normal client readers and events, and clean them up when their owner changes.

App lifecycle and operating system integration

Section titled “App lifecycle and operating system integration”

Native lifecycle observation is installed before the first client starts and uses the process’s actual initial state, including a background launch. It suspends and resumes stream work with the native lifecycle. Do not add a second JavaScript AppState driver for the same SDK streams. Await message processing before requesting the next reader value. Release readers and clients when their app owner is finished. See Streams and Events.

The app owns notification permissions and token acquisition, media selection, and secret-key persistence. Use the SDK’s integrated attachment workflow with native file paths and its notification API with APNs or FCM tokens. A JavaScript listener cannot process a push while the operating system has suspended or terminated its runtime. Native background handlers and notification extensions need their own bounded processing and storage access.

  • Native module missing: rebuild the native app, install iOS pods, and confirm autolinking. Metro reloads and Expo Go do not add the TurboModule.
  • Engine or architecture error: enable Hermes and New Architecture, and use a packaged target from the table above.
  • Backend unreachable: use a device-reachable URL. Check credentials, TLS, and the app’s network permissions.
  • Database cannot open: keep the same database path and encryption key. Do not open an active database from two runtimes or processes.
  • Push arrives without a message: treat it as a sync hint. Process welcomes, sync the conversation, then apply consent and display rules. Normal foreground sync still handles delayed or missed pushes.
  • Metro reports private import failures: import from @xmtp/react-native-sdk or @xmtp/react-native-sdk/react, never from dist or generated binding paths.

For pre-8.0.0 apps, follow the React Native upgrade guide.