Wrapping a native SDK in a React Native native module
In 2018 we needed Log in with Uber in React Native and no maintained bridge existed. Building a native module taught me about JS and native contracts.
From 2018 · 2 min read · Published September 19, 2026
In the fall of 2018, our React Native app at Freebird Rides needed users to connect their Uber accounts. Uber shipped native SDKs for iOS and Android. It did not ship anything for React Native, and the community bridges we found were stale, so we wrote our own React Native native module.
So we wrote our own: react-native-uber-sso. It's small, a few hundred lines per platform, but it's a good case study in what actually matters when you put a bridge between JavaScript and two native codebases.
One native module shape, two platforms
The single most useful decision was that both platforms emit exactly the same payload over a single event:
export interface IUberSSOResult {
data: any
status: string // "success" | "failure"
type: string
}Success carries the access and refresh tokens in data. Failure carries an error message. JavaScript never has to ask which platform it's on, and the screen that handles login has one code path.
On the JavaScript side, subscribing returns a disposer, so a component can clean up after itself:
uber.onSSOUberSSOAccessToken = callback => {
const listener = NativeAppEventEmitter.addListener(
'onSSOUberSSOAccessToken',
_data => {
try {
callback(JSON.parse(_data))
} catch (_error) {
callback(null)
}
}
)
return function remove() {
listener.remove()
}
}Even a malformed payload calls the callback. The screen always hears back.
Cancel is a result, not an absence
The commit history is honest about where the bugs were. Most of them landed in one week in September 2018:
fix(ios): send react the access and refresh tokensfix(android): return error to react on cancelled loginfix: uber error when denying access
Two of those three are about the unhappy path. When a user opened the Uber login sheet and backed out, the native side originally did nothing. From JavaScript's point of view, the user tapped a button and the universe went quiet. The fix was to treat cancellation as a first-class failure event:
@Override
public void onLoginCancel() {
handleError(uberOnSSOFailure, "User cancels login");
}It's the same lesson as the promise that never resolved: every path out of native code has to report back, including the ones where the user changed their mind.
A bug I'd catch today
Rereading this code eight years later, one line jumps out on the Android side:
.setEnvironment(environment == "sandbox"
? SessionConfiguration.Environment.SANDBOX
: SessionConfiguration.Environment.PRODUCTION)In Java, == compares object references, not string contents. A "sandbox" string that arrived from JavaScript is generally a different object from the "sandbox" literal, so this check can quietly fall through to production. The iOS side got it right with isEqualToString:. The correct Java is "sandbox".equals(environment).
It's a good reminder of the real cost of a bridge: you're writing the same logic in three languages, each with its own traps, and the bug only needs to exist in one of them.
Related: The React Native bridge promise that never resolved · The React Native AndroidX migration week