/more-reacty-native-demo

Experiment with a more reacty API for React Native

Primary LanguageObjective-C++

A more Reacty React Native experiment

TL;DR: Import-less native views and modules, ex: app/index.tsx

export default function Home() {
  return (
    <div
      style={{
        flex: 1,
        alignItems: "stretch",
        justifyContent: "center",
        gap: 16,
      }}
    >
      <p onPress={() => native?.calendar.openModal()}>Hey</p>
    </div>
  )
}

Built-in Views

React Native needs to have less boilerplate (opinion: built-in react views). This experiment patches React Native to support using lowercase JSX views that are registered just in time.

// Map View available via <map-view />
@ReactView(jsName: "map-view")
class MapView: RCTViewManager {

  @ReactProperty
  var zoomEnabled: Bool?

  override func view() -> UIView {
    MKMapView()
  }
}

Which can be used in JS-land (notice: no imports are required). This is because the React babel plugin converts this code to React.createElement("map-view", { zoomEnabled: true }) which is then passed to React Native.

function App() {
  return <map-view zoomEnabled style={{ flex: 1 }} />;
}

This can be typed the same as views in react-dom or React Three Fiber:

declare global {
  namespace JSX {
    interface IntrinsicElements {
      /** MKMapView */
      "map-view": import("react-native").ViewProps & { zoomEnabled?: boolean };
    }
  }
}

To make this work, I patched react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry.js to instantiate the view when it's missing:

if (typeof name[0] === "string" && /[a-z]/.test(name[0])) {
  // Just-in-time register the native view for lowercase names to replicate the behavior of
  // react-dom.
  const createReactNativeComponentClass = require("./createReactNativeComponentClass");
  const getNativeComponentAttributes = require("../../ReactNative/getNativeComponentAttributes");

  // Essentially just `requireNativeComponent('...');`.
  createReactNativeComponentClass(name, () =>
    getNativeComponentAttributes(name)
  );
  callback = viewConfigCallbacks.get(name);
}

Built-in APIs

In the browser, APIs are just installed on the JS global object, e.g. navigator.geolocation. This experiment patches React Native to work similarly by using the global native object, e.g. native.geolocation instead of importing react-native and using NativeModules.

declare var native: typeof import("react-native").NativeModules;
if (typeof native === "undefined") {
  globalThis.native = new Proxy(
    {},
    {
      get(target, prop) {
        const NativeModules = require("react-native").NativeModules;
        if (prop in NativeModules) {
          return NativeModules[prop];
        }
      },
    }
  );
}

This makes web interop a bit nicer too because you can just do:

if (typeof native !== "undefined") {
  native.geolocation.getCurrentPosition();
}

This can be typed like the browser (in the future this can be generated from parsing the Swift/Kotlin code):

declare global {
  interface Window {
    native: typeof import("react-native").NativeModules & {
      /** Custom native module */
      geolocation: {
        /** Get the current position. */
        getCurrentPosition: () => void;
      };
    };
  }
}

Result

The result is a React Native that feels more like the web and requires substantially less boilerplate/bundling. Standard web projects start with only a handful of imports, but React Native has thousands (~1,945 last I checked). Even with the fastest bundler in the world, this will still require seconds to create the graph.

I've demonstrated here that you can still have types, doc blocks, and all the other benefits of React without the boilerplate of React Native.

Overall, this workflow lends itself better to jumping between JS and native code to expose new APIs or views. It's also easier to maintain because there are fewer bridging APIs to keep in sync. In the future, we should generate the TypeScript types and doc blocks from the Swift/Kotlin code, but that's a problem for another day.

Web interop

Though it's not perfect, and possibly more confusing than helpful— I did play with adding a div view which just re-exports RCTView. Works like a View component but uses syntax that lends itself much better to use with a shared web codebase.

Other cool stuff

I used this Swift Macros library ReactBridge with Expo Router. I found this package in the Indeed Job Search iOS app.