skip to content

How do you expose a native view from an Expo module with View and Prop, and how does JavaScript render and call it?

level: seniorimportance: should knowfreq 20%

answer

  1. a View block inside the module
  2. the class extends ExpoView
  3. Prop setters, optional default
  4. view AsyncFunction on the ref
  5. requireNativeView from expo

basics

~20 s

Declare View(MyView) inside the module definition with a Prop setter per prop, Events for callbacks and optional AsyncFunctions; the view class extends ExpoView. JavaScript gets a component from requireNativeView('ModuleName') and calls view functions through a ref.

solid answer

~40 s

The module definition gets a `View(HapticPadView.self)` block (`HapticPadView::class` in Kotlin). Each `Prop("intensity") { view, value in ... }` is a setter the renderer calls when that prop changes; an optional second argument is a default applied when JavaScript sends `null`. The view class extends `ExpoView`; on Android that is required, and it takes a `Context` and `AppContext`. `Events` inside the block declares callback props, fired through an `EventDispatcher`, because function-typed props are not supported. An `AsyncFunction` inside the block is attached to the component's ref, receives the view as its first argument and runs on the main thread. In JavaScript, `requireNativeView<Props>('HapticPad')` from `expo` returns a component that I render like any other and call with `ref.current?.pulse()`.

code

swift · 37 lines
swift
import ExpoModulesCore
import UIKit

class HapticPadView: ExpoView {
  let onPulse = EventDispatcher()
  var intensity: CGFloat = 1.0

  required init(appContext: AppContext? = nil) {
    super.init(appContext: appContext)
    addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
  }

  @objc func handleTap() { pulse() }

  func pulse() {
    UIImpactFeedbackGenerator(style: .medium).impactOccurred(intensity: intensity)
    onPulse(["intensity": intensity])
  }
}

public class HapticPadModule: Module {
  public func definition() -> ModuleDefinition {
    Name("HapticPad")

    View(HapticPadView.self) {
      Events("onPulse")

      Prop("intensity", 1.0) { (view: HapticPadView, value: Double) in
        view.intensity = CGFloat(value)
      }

      AsyncFunction("pulse") { (view: HapticPadView) in
        view.pulse()
      }
    }
  }
}

go deeper

for a junior

Recall that a View block exports a native view, Prop defines its props, and requireNativeView returns the component JavaScript renders.

for a middle

Explain the ExpoView requirement, Prop defaults for null, events for callbacks, and how view AsyncFunctions reach the ref on the main thread.

for a senior

Design a native view's API end to end, props, events and imperative functions, and explain when an Expo View is preferable to a spec-driven Fabric component.

for a principal

Decide when a feature justifies a native view at all versus composing core components, weighing maintenance on two platforms against the user-facing gain.

## The View block An Expo module can export a **native view** as well as functions. Inside the module's `ModuleDefinition` you add a **`View`** component that names the native class and a definition block: - Swift: `View(HapticPadView.self) { ... }` - Kotlin: `View(HapticPadView::class) { ... }` The main components inside the block are **`Prop`**, **`Events`**, **`AsyncFunction`** and, on Android, **`GroupView`** for views that host children, plus view lifecycle hooks such as **`OnViewDidUpdateProps`**. ## The view class The native class should extend **`ExpoView`**: - On **Android** this is required. The class keeps the `(context: Context, appContext: AppContext)` constructor, because `expo-modules-core` creates the instance. - On **iOS** it is optional but recommended: `ExpoView` extends React Native's base view, which handles some styles, such as borders, and accessibility. You override `required init(appContext: AppContext? = nil)` and call `super.init(appContext:)`. Extending `ExpoView` also gives the view its `appContext`, the way it communicates with other modules and the runtime. ## Props **`Prop("name") { view, value in ... }`** registers a setter. The renderer calls it with the view instance and the new value whenever JavaScript changes that prop, and the value is converted with the same type rules as function arguments (primitives, `Record`, `Enumerable`, colours, URLs): 1. `Prop("intensity", 1.0) { ... }` supplies a **default** used when the prop is set to `null`, for example when JavaScript removes it. 2. **`OnViewDidUpdateProps`** runs after a batch of props has been applied, which is the place for work that needs several props at once. 3. **Function-typed props are not supported**; callbacks go through events. ## Callbacks and imperative calls - **Events**: `Events("onPulse")` in the block plus an `EventDispatcher` property named `onPulse` on the view class. Calling `onPulse([...])` invokes the JavaScript prop with the payload under `nativeEvent`. - **View AsyncFunctions**: an `AsyncFunction("pulse") { (view: HapticPadView) in ... }` inside the block is attached to the component's **ref**. It receives the view instance first and **always runs on the main thread**, so it can touch UI directly. ## The JavaScript side `requireNativeView` from `expo`, an alias of `requireNativeViewManager` in `expo-modules-core`, takes the **module name** and returns a React component: | JavaScript | maps to | |---|---| | `<HapticPad intensity={0.6} />` | the `intensity` `Prop` setter | | `onPulse={(e) => e.nativeEvent}` | the `onPulse` event dispatcher | | `ref.current?.pulse()` | the view `AsyncFunction` named `pulse` | | `style={{ height: 200 }}` | layout, handled by React Native as for any view | Declare the props type yourself and pass it as the generic, since there is no spec to generate it from. ## Android specifics - **Constructor**: the Kotlin view keeps `(context: Context, appContext: AppContext)` and passes both to `ExpoView`; adding parameters breaks instantiation. - **Children**: a view that hosts React children declares **`GroupView`** with `AddChildView`, `GetChildCount`, `GetChildViewAt` and the remove actions. - **Teardown**: **`OnViewDestroys`** runs when React Native stops using the view; it exists on Android only, and on iOS the view's deinitialiser plays that role. - **`PropGroup`** batch-registers props with a shared setter; the docs note it is used internally and most modules should use individual `Prop` definitions. ## Where this fits Expo's design notes present the `View` DSL as renderer-agnostic: the module author does not write separate old-renderer and Fabric view managers. For views that need a spec-driven Fabric component, for example to share C++ with other platforms, React Native's own native component specs are the alternative. For an app-specific surface such as a haptic test pad, the Expo `View` block keeps the view, its props, its events and the module's functions in one Swift file and one Kotlin file.

  • On which thread does a view AsyncFunction run, and why does that matter?
    On the main thread, always. It receives the native view instance as its first argument, and UI objects must be touched from the main thread, so the API dispatches there for you. Keep the body short; heavy work belongs in a module-level AsyncFunction on a background queue.
  • What happens to an Expo view prop declared with a default when JavaScript stops passing it?
    The renderer calls the setter with `null`, and the `Prop` substitutes the declared default, so the view returns to a known state. Without a default, the setter must accept an optional type and handle `nil` or `null` itself.

saying these in an interview costs you the question

  • A callback can be passed to a native view through a function-typed Prop.
  • View AsyncFunctions run on a background queue like module ones.
  • On Android any View subclass works without extending ExpoView.
  • requireNativeView takes the native class name rather than the module name.
  • A Prop setter runs only once, when the view is created.