skip to content

How does an Expo module deliver native events to JavaScript with Events and sendEvent, and how do view events differ?

level: seniorimportance: should knowfreq 25%

answer

  1. declare names, then emit
  2. sendEvent with a payload map
  3. OnStartObserving and OnStopObserving
  4. addListener, useEvent, useEventListener
  5. views use an EventDispatcher property

basics

~20 s

An Expo module lists its event names with Events and emits them with sendEvent(name, payload); JavaScript subscribes through the module's addListener or the useEvent hooks. A view event instead uses an EventDispatcher property and arrives as a prop callback with nativeEvent.

solid answer

~40 s

In the module definition I declare `Events("onPatternPlayed")`, then call `sendEvent("onPatternPlayed", payload)` from native code with a dictionary on iOS or a map or `Bundle` on Android. The module object returned by `requireNativeModule` extends Expo's `EventEmitter`, so JavaScript subscribes with `Module.addListener(name, listener)`, which returns a subscription with `remove()`, or with the `useEventListener` and `useEvent` hooks from `expo`, which clean up on unmount. `OnStartObserving` and `OnStopObserving` let native code start and stop the underlying system listener only while JavaScript listens. Events that belong to one view instance are different: the `View` block declares `Events`, the view class holds an `EventDispatcher` property with the same name, and JavaScript passes a callback prop that receives the payload under `nativeEvent`.

code

kotlin · 15 lines
kotlin
class HapticPatternsModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("HapticPatterns")

    Events("onPatternPlayed")

    OnStartObserving("onPatternPlayed") { /* start any system observer */ }
    OnStopObserving("onPatternPlayed") { /* stop it again */ }

    AsyncFunction("playPatternAsync") { timingsMs: LongArray ->
      // ...play the waveform...
      sendEvent("onPatternPlayed", mapOf("durationMs" to timingsMs.sum()))
    }
  }
}

go deeper

for a junior

Recall the pair: Events declares names, sendEvent emits them, and JavaScript subscribes with addListener or the useEventListener hook.

for a middle

Explain the typed event map on NativeModule, subscription cleanup, and why view events use an EventDispatcher and nativeEvent instead.

for a senior

Show how you scope native observers with OnStartObserving and OnStopObserving and choose module events versus view events for a real API without leaking listeners.

for a principal

Define an event contract for native modules across the app: naming, payload shapes, typing, and who guarantees listeners are removed.

## Module events: declare, then emit Native code often needs to tell JavaScript that something happened: a haptic pattern finished, a sensor changed, the clipboard updated. The Expo Modules API models this as **module events**: 1. **Declare** the names in the definition with **`Events("onPatternPlayed", ...)`**. 2. **Emit** from anywhere in the module with **`sendEvent(eventName, payload)`**. The payload is a dictionary (`[String: Any?]`) on iOS and a `Map<String, Any?>` or `Bundle` on Android. 3. **Subscribe** in JavaScript on the module object. ## Subscribing from JavaScript The object that `requireNativeModule` returns extends Expo's **`EventEmitter`**. Declaring the module's TypeScript class as `NativeModule<Events>` types the event map, so listener payloads are checked. The ways to subscribe are: - **`Module.addListener('onPatternPlayed', listener)`** returns a subscription; call **`remove()`** when done. `removeAllListeners(name)` and `listenerCount(name)` also exist. - **`useEventListener(Module, 'onPatternPlayed', listener)`** from `expo` adds the listener on first render and removes it on unmount, always calling the latest listener. - **`useEvent(Module, 'onPatternPlayed', initialValue)`** from `expo` returns the latest payload as state, re-rendering when a new event arrives. In components, the hooks are the safer default because they cannot leak a subscription. ## Observing only while someone listens Many events wrap a system listener that costs battery or CPU. **`OnStartObserving`** runs when the first JavaScript listener is added and **`OnStopObserving`** when the last is removed; both accept an event name to scope them to one event. Registering the system observer in the first and removing it in the second means the native side does no work when nobody listens. Expo's own clipboard example registers and unregisters its change listener exactly this way. ## View events are a different mechanism An event that belongs to **one rendered view**, such as a tap on a particular haptic pad, cannot use `sendEvent`, which is module-wide. The Expo docs call the alternative **view callbacks**: 1. Inside the `View(...)` block, declare the names with **`Events("onPulse")`**. 2. In the view class, which extends **`ExpoView`**, declare a property of type **`EventDispatcher`** with **the same name** as the event: `let onPulse = EventDispatcher()` in Swift, `val onPulse by EventDispatcher()` in Kotlin. 3. Call it like a function with a payload: `onPulse(["intensity": 0.6])`. 4. In JavaScript, pass a **callback prop** with that name to the native component; the payload arrives under **`event.nativeEvent`**. This is also how a native view offers callbacks at all, because `Prop` does not accept function-typed props. ## Comparison | | module event | view event | |---|---|---| | declared in | the module definition | the `View` block | | emitted with | `sendEvent(name, payload)` | an `EventDispatcher` property | | received in JavaScript via | `addListener`, `useEventListener`, `useEvent` | a callback prop on the component | | payload location | the listener's argument | `event.nativeEvent` | | scope | every listener of the module | the one view instance | ## Payloads and typing - **Keep payloads flat and serialisable**: strings, numbers, booleans, arrays and nested dictionaries convert cleanly on both platforms. - **Type the map once**: the `NativeModule<Events>` generic gives `addListener` and the hooks typed payloads, so a renamed field breaks the TypeScript build instead of a screen. - **Android view events may carry a typed payload**; a value that does not convert to an object is wrapped as `{ payload: value }`, so the JavaScript side must read it from there. - **Name events after what happened** (`onPatternPlayed`, `onPulse`), matching React Native's `on` prefix convention for callback props. ## Pitfalls in review - **Forgetting cleanup**: an `addListener` in a `useEffect` without `remove()` in the cleanup keeps firing after the screen is gone. - **Emitting a name not listed in `Events`**: declare every name so the module's contract, and its TypeScript event map, stay complete. - **Using a module event for per-view interaction**: every mounted instance's listener would react to every view's event.

  • Why pair OnStartObserving with OnStopObserving instead of registering the system observer in OnCreate?
    `OnCreate` runs when the module initialises, so an observer registered there works for the whole session even when no JavaScript code listens. Starting it in `OnStartObserving` and removing it in `OnStopObserving` ties the native cost to the time a listener exists.
  • How does a native view pass a callback to JavaScript if Prop cannot take a function?
    Through a view event. The `View` block declares the name with `Events`, the view class holds an `EventDispatcher` property of the same name and calls it with a payload, and the JavaScript component receives it as a callback prop whose argument carries the payload in `nativeEvent`.

saying these in an interview costs you the question

  • Module event listeners receive their payload under nativeEvent.
  • Per-view taps should be sent with the module's sendEvent.
  • addListener subscriptions clean themselves up when the component unmounts.
  • A view event's payload is the callback's argument itself, not nativeEvent.
  • A view callback is passed down as a function-typed Prop.