mobile

Designing @plato/rn-chat as a reusable React Native SDK

What changed when chat became a reusable React Native library instead of app-specific code, the API design decisions, the imperative ref pattern, Sockudo integration, and what self-hosting as an SDK forces you to think about.

Plato’s chat feature existed in one app before it existed in three.

The first version was app-specific, a collection of screens and hooks tied to the navigation structure of the main Plato mobile app. It worked well enough. Then the requirement arrived: the same chat capability needed to be embeddable across multiple React Native apps, each with its own navigation setup, its own auth flow, its own release cadence.

Copying the feature three times was briefly considered and quickly dismissed. Chat has too many moving parts for copied code to stay consistent: realtime presence and typing indicators, message threads, attachment handling, a local cache, auth token injection, push notification wiring. Every bug fix would need to go to three places. The products would drift.

The answer was @plato/rn-chat, a proper React Native library, published to a private Nexus registry, with a versioned API. This is a walkthrough of the design decisions that mattered and where they came from.

The core constraint: don’t assume navigation

The first hard problem was how to embed the SDK without dictating how the host app works.

If @plato/rn-chat required a specific screen structure, a ChatNavigator that the host had to register in their navigation tree, or a route name the host had to reserve, then every host app would have to restructure itself to accommodate the SDK. That’s the wrong inversion of control.

The design I landed on was an imperative ref API. The SDK exports a ChatRoot component that you mount somewhere in your component tree (usually near the root), and a ref with two methods:

const chatRef = useRef<ChatHandle>(null);

// Mount once at the app root
<ChatRoot ref={chatRef} config={chatConfig} />

// Invoke from anywhere
chatRef.current?.open();            // open to chat list
chatRef.current?.openChat(chatId);  // open to specific chat

ChatRoot renders a modal overlay managed entirely by the SDK. The host app doesn’t need a route for it. The host app doesn’t need to pass it anything through navigation params. It mounts, it waits, and when you call open() or openChat(), it activates.

This pattern means the SDK is embeddable rather than merely installable. A feature that needs specific host cooperation to function is a partial SDK, the host still has to think about how to integrate it at a deep level. A feature you can mount once and call imperatively is a real SDK.

The tradeoff: side effects from modal management (keyboard dismissal, safe area handling, gesture conflicts) happen inside the SDK, and the host has less control over them. We documented the known edge cases per host app.

Auth token injection

The chat service needs an auth token to authenticate WebSocket connections and API calls. The host app has that token, it came from the auth flow the user went through.

The config interface handles this:

interface ChatConfig {
  baseUrl: string;
  wsHost: string;
  getAuthToken: () => Promise<string>;
  userId: string;
  userDisplayName: string;
  onUnreadCountChange?: (count: number) => void;
}

getAuthToken is a callback rather than a static value. This matters because tokens expire. If we accepted a static token at initialization, the SDK would break after token expiry. The callback means the SDK can request a fresh token whenever it needs one, typically before opening a WebSocket connection or making an API call. The host app’s auth layer handles the token refresh; the SDK just asks for whatever’s current.

This pattern generalizes: any config value that might change over time should be a callback rather than a value. Static config values are fine for things like baseUrl. For anything tied to session state, callbacks are safer.

Sockudo integration inside the SDK

The realtime layer inside @plato/rn-chat connects to Sockudo (our self-hosted Pusher-protocol WebSocket server). This is the same Sockudo instance that Plato’s web app uses, which means the chat channels are shared, a message sent from the mobile SDK shows up on the web, and vice versa.

The SDK wraps pusher-js/react-native and manages the connection lifecycle internally:

const pusher = new Pusher(config.wsKey, {
  wsHost: config.wsHost,
  wsPort: 6001,
  forceTLS: false,
  enabledTransports: ['ws'],
  auth: {
    headers: {
      Authorization: `Bearer ${await config.getAuthToken()}`,
    },
  },
});

const channel = pusher.subscribe(`private-chat.${chatId}`);
channel.bind('message.new', handleNewMessage);
channel.bind('typing', handleTyping);

The host app passes the wsHost. The SDK handles subscription management, reconnection on network changes (React Native has specific network event handling), and cleanup on component unmount. The host app doesn’t see any of this.

One challenge: Sockudo connection state has to survive the host app navigating away and back. The SDK keeps the Pusher connection alive as long as ChatRoot is mounted, even if the modal is closed, so messages that arrive while the user is elsewhere still register as unread. The onUnreadCountChange callback surfaces this to the host app, which can update its notification badge.

Peer dependencies and the version headache

React Native library packaging has one consistent pain point: peer dependencies.

@plato/rn-chat has peer dependencies on react, react-native, @react-navigation/native, and react-native-reanimated (for the modal animations). Every host app already has these. But React Native’s ecosystem has historically been poor at consistent peer dependency resolution, wrong versions installed alongside each other, react-native-reanimated failing because it expects a specific Babel plugin, navigation versions that don’t match.

The practical solution: document the exact versions we test against in the package README, use a strict-but-not-too-strict peer dependency range ("react-native": ">=0.71.0"), and include a peerDependenciesMeta block marking some as optional:

{
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-native": ">=0.71.0",
    "@react-navigation/native": ">=6.0.0"
  },
  "peerDependenciesMeta": {
    "@react-navigation/native": {
      "optional": true
    }
  }
}

Navigation is optional because not every host app uses React Navigation, one of our apps uses Expo Router. For those, we provide a different embedding pattern that doesn’t depend on navigation context.

Publishing to Nexus

Distribution without a publish workflow isn’t distribution, it’s file copying. We publish to a private Nexus npm registry, which gives us versioned packages, a consistent install experience across apps, and dependency resolution that works with yarn’s lockfile.

The package.json publish config:

{
  "publishConfig": {
    "registry": "https://nexus.internal/repository/npm-private/"
  }
}

Consuming apps add a .npmrc entry pointing to the private registry for the @plato scope:

@plato:registry=https://nexus.internal/repository/npm-private/

Release workflow uses release-it with conventional commits. When we’re ready to cut a release, we run yarn release, which bumps the version per conventional commit semantics (patch for fixes, minor for features, major for breaking changes), generates the changelog, creates a git tag, and publishes to Nexus.

This matters more than it might seem. Before we had a proper release workflow, versions were bumped manually and changelogs were inconsistent. Host app developers didn’t know what changed between versions and were reluctant to upgrade. With conventional changelog generation, the changelog is accurate and the version number communicates what kind of change it is. Upgrades happen faster because the risk is legible.

What an SDK boundary forces you to think about

The biggest shift from app feature to SDK is that you can’t take shortcuts on the contract.

In an app, if a component assumes the user is logged in, you add a guard and move on. In an SDK, if your contract assumes the user is logged in at initialization time, every host app has to deal with the edge case of the SDK being mounted before auth completes. You can’t fix it with a guard buried in your own codebase, you’ve pushed the problem into someone else’s code.

Every assumption you make in the SDK’s public API becomes a requirement the host has to meet. The narrower and more explicit you make those requirements upfront, the less friction there is when someone tries to embed it in a context you didn’t think about.

The getAuthToken callback was the right call because it deferred the “when do we have a token” question to the host. A static authToken prop would have been easier to implement but harder to use correctly. Callbacks for session state, functions for anything that might change, that’s the pattern.

The other thing the SDK forced: thinking about failure modes that app code often glosses over. What does the SDK do when the WebSocket connection fails? When an attachment upload times out? When the token is invalid? In app code you might show a generic error and log it. In an SDK, you need to surface the right error through your public API so the host can handle it appropriately. That made the SDK’s error handling more explicit than it probably would have been if it was just app code.

A year into multi-app deployment

@plato/rn-chat is running in three apps now. The fixes go to one place. The unread count badge wiring is consistent. The realtime layer is shared.

The imperative ref API held up better than I expected, host app teams found it intuitive to use and it didn’t require any changes to how their navigation was structured. The Nexus publishing workflow means upgrades happen on a normal cadence rather than being deferred because the upgrade process is painful.

The peer dependency situation is still a manageable headache. It’s probably not fully solvable, it’s a known problem in the React Native packaging ecosystem, but documenting the tested versions and being explicit about optionals keeps it from being a blocker.

If I were starting over: I’d define the ChatConfig interface before writing any implementation code. The interface is the contract, and the contract should come before the code that implements it.