esri-react-native
v1.0.1
Published
React Native Expo module for ArcGIS Maps SDK with map visualization and location services
Downloads
351
Maintainers
Readme
esri-react-native
React Native Expo module for ArcGIS Maps SDK with map visualization, location services, and comprehensive observability features.
Features
- 🗺️ ArcGIS Maps Integration: Full-featured mapping with ArcGIS Maps SDK for iOS and Android
- 📍 Markers & Interaction: Interactive markers with drag support and tap events
- 🏔️ Vector Tiles: Support for custom vector tile layers (KOOP, custom services)
- 📍 Location Services: User location display with various auto-pan modes
- 🔐 Authentication: Support for ArcGIS API keys and custom Bearer tokens
- 🐛 Observability: Advanced structured logging (native + JS) with normalization, deduplication, scope, sequence IDs, memory usage (iOS) and correlation hooks.
- ⚡ Performance: Optimized rendering and thread-safe operations
- 🛡️ Defensive: Comprehensive input validation and graceful error handling
Architecture Overview
Both native layers use a thin EsriReactNativeView (under 250 lines) that only orchestrates, splitting responsibilities into dedicated packages (full tables in API_REFERENCE.md).
Android — android/src/main/java/expo/modules/esrireactnative/:
core/— pure Kotlin (noandroid.*/com.arcgismaps.*imports):Validation,ColorParser,FlyToPlanner,VectorTileSpec,MarkerSpec,BasemapSpec,HostAllowlist,Geodesy,EventPayloads,ZoomScale.controllers/— SDK-facing adapters, one responsibility each, receiving a logger and coroutine scope by constructor:MapHostController,BasemapController,ScaleLimitsController,VectorTileController,MarkerController,NavigationController,InitialViewpointController,LocationController.geo/—GeometryUtils(envelope operations, Haversine fallback; the only controller-adjacent code allowed to touchcom.arcgismaps.*).networking//logging/—HttpAuth/AllowedHostsAuthInterceptor(selective Bearer token injection, HTTP client configured once per process) andNativeLogger(per-view, no singleton).
iOS — ios/, mirroring the same split in Swift:
Core/— pure Swift (noimport ArcGIS/UIKit/ExpoModulesCore):Validation,ColorParser,FlyToPlanner,VectorTileSpec,MarkerSpec,BasemapSpec,HostAllowlist,EventPayloads,ZoomScale, and the generatedVersion.swift.Controllers/— SDK-facing adapters (@MainActor), one responsibility each, receiving anEsriLoggerby constructor:MapHostController,ScaleLimitsController,BasemapController,VectorTileController,MarkerController,NavigationController,InitialViewpointController,LocationController.Networking/HttpAuth.swift—AuthState(lock-protected Bearer token + allowlist) andAllowedHostsAuthInterceptor, assigned once per process toArcGISURLSession.dataTaskInterceptor/downloadTaskInterceptor(the SDK's official interception mechanism, mirroring Android'sInterceptor) viaEsriHttpSetup.ensureConfigured().SwiftUI/MapContainerView.swift— theArcGIS.MapViewwrapper (gestures, viewpoint tracking), with the logger injected.Logging/NativeLogger.swift— per-view, no singleton.
This keeps responsibilities isolated and maintainable without changing the public JS API.
Testing
JUnit unit tests (android/src/test/java/.../core/) cover every core/ file against fixtures shared with JS and Swift (test-fixtures/*.json): validation, color parsing, flyTo duration/curve math, vector tile spec/signature, marker spec, basemap spec, host allowlist, geodesy. Run them with npm run test:android (via example/android, see CLAUDE.md for the fixture-as-Gradle-input gotcha).
swift test (npm run test:swift, requires a full Xcode install — Command Line Tools alone can't link XCTest) covers the same shared fixtures against ios/Core/ (ios/Tests/CoreTests/), plus iOS-only cases (finalURL, MarkerDiff, EventPayloads, noStateDuration, intermediateScale) documented as not fixture-driven where iOS behavior intentionally diverges from Android (see docs/sdk-notes.md).
Behavior coupled to the ArcGIS runtime (controllers) is validated by compiling and via the manual parity checklist (docs/parity-checklist.md) in the example app and in downstream consumers.
Installation
npm install esri-react-nativeRequirements
- Expo SDK 54+
- React Native 0.81+
- iOS 17.0+
- Android API 28+
- Xcode (full install, not just Command Line Tools) on first install, to vendor the ArcGIS xcframeworks
Config Plugin
Add the plugin to your Expo config (app.json/app.config.ts):
{
"expo": {
"plugins": [
[
"esri-react-native",
{
"allowedHosts": ["myserver.example.com"]
}
]
]
}
}Options
| Option | Type | Default | Description |
| -------------------------------- | ---------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| iosDeploymentTarget | string | "17.0" | Floor for the iOS deployment target (Podfile.properties.json and every pbxproj build configuration). Only raises the current value, never lowers it. |
| androidMinSdkVersion | number | 28 | Floor for android.minSdkVersion in gradle.properties. Same floor-only behavior as iosDeploymentTarget. |
| addLocationPermissions | boolean | true | Adds NSLocation*UsageDescription (iOS) and ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION (Android) when not already present. |
| allowedHosts | string[] | [] | Hosts, or full URLs, allowed to receive the Authorization: Bearer header. Normalized to lowercase hostnames. |
| deriveAllowedHostFromExtra | boolean | true | Also derives an allowed host from expo.extra.koopServerUrl, when present. |
| addATSExceptionForAllowedHosts | boolean | true | Adds an App Transport Security exception (plain HTTP) for allowedHosts, scoped to atsExceptionEnvs. |
| atsExceptionEnvs | AppEnv[] | ["development"] | Environments where the ATS exception is applied. AppEnv is "development" \| "preview" \| "production". |
| env | AppEnv | process.env.APP_ENV → process.env.EAS_BUILD_PROFILE → "development" | Overrides the environment used to resolve atsExceptionEnvs. |
What it generates
- iOS:
NSLocation*UsageDescriptionentries,EsriAllowedHostsinInfo.plist, ATS exceptions forallowedHosts(only in the envs listed inatsExceptionEnvs), and a floor onIPHONEOS_DEPLOYMENT_TARGET/ios.deploymentTarget. - Android:
ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATIONpermissions, the Esri Maven repository in the rootbuild.gradle, and a floor onandroid.minSdkVersioningradle.properties. expo.extra.esriAllowedHostswith the resolved, normalized host list, for consumption from JS.
Vendoring the ArcGIS SDK
The ArcGIS xcframeworks aren't published on npm. A postinstall script (npm run ios:vendor) downloads and unpacks them into ios/Vendor on first install. It's idempotent — tracked by an ios/Vendor/.version marker, so it's skipped once the expected version is already there — and requires a full Xcode install (not just Command Line Tools); without Xcode it warns instead of failing. If ios/Vendor is still missing when you build, EsriReactNative.podspec raises a clear error rather than failing with an obscure linker error — re-run npm run ios:vendor on a machine with Xcode, or copy ios/Vendor from one that already has it.
Usage
Basic Map
import React from "react";
import { StyleSheet } from "react-native";
import { EsriReactNativeView } from "esri-react-native";
export default function App() {
return (
<EsriReactNativeView
style={styles.map}
apiKey="YOUR_ARCGIS_API_KEY"
showUserLocation={true}
autoPanMode="navigation"
initialCenter={{ latitude: 40.7128, longitude: -74.006 }}
initialScale={50000}
vectorTiles={[
{
id: "osm-vector",
url: "https://basemaps.arcgis.com/arcgis/rest/services/OpenStreetMap_v2/VectorTileServer",
reference: false,
},
]}
/>
);
}
```
### Licensing
ArcGIS Runtime requires both an API key (for basemaps/services) and optionally a license key (to exit Developer mode and unlock additional capabilities). If you do not provide a `licenseKey` the app runs in Developer mode.
Minimal usage:
```tsx
<EsriReactNativeView apiKey={"YOUR_API_KEY"} licenseKey={"runtimelite,1000,..."} />
```
The license string format matches the ArcGIS documentation. Invalid or blank values are ignored with a structured error event.
const styles = StyleSheet.create({
map: {
flex: 1,
},
});With Interactive Markers
Logging & Observability
The library emits structured log events you can intercept via the onLog prop. Every log passes through a normalizer so that both native (iOS/Android) and JS logs share the same shape.
import { LogEntry } from "esri-react-native";
function handleLog(entry: LogEntry) {
// Send to Sentry, Datadog, etc.
if (entry.level === "error") {
console.error("[MapError]", entry);
}
}
<EsriReactNativeView onLog={handleLog} debugLogs />LogEntry Fields
| Field | Type | Description |
| ---------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| level | debug \| info \| warn \| error | Normalized severity. Non‑standard domain levels (INIT, ESRI, ZOOM…) are mapped to info with originalLevel. |
| message | string | Human readable message (never empty, fallback '(no message)'). |
| detail | string? | Additional context or error details. |
| where | string? | Code location (e.g. EsriReactNativeView.goTo). |
| scope | string? | High‑level domain extracted from where or original level (e.g. ZOOM, INIT). |
| platform | ios \| android | Origin platform (guessed in JS if missing). |
| ts | number | Unix seconds timestamp. |
| thread | string? | main or background (native only). |
| sequence | number? | Monotonic counter (per process). Useful for ordering. |
| originalLevel | string? | Raw level before mapping (e.g. ZOOM). |
| memoryMB | number? | Approximate resident memory (iOS). |
| version | string? | Native bundle version (iOS). |
| operationId | string? | Correlation id for future grouped operations (flyTo/goTo). |
| duplicateCount | number | Number of times the same message repeated in a short window (starts at 1). |
Deduplication
Rapid identical messages within a 1.5s window increment duplicateCount instead of flooding output. After a small interval the count resets automatically. Deduplication is automatic and scoped per view instance; there is no public API to reset it manually.
Error Handling
Errors use a separate onError callback with fields: code, message, detail?, where?, ts, thread?, originalErrorMessage?.
Recommended Consumption Pattern
- Filter by
levelfor routing (e.g., send onlyerror/warnto external monitoring). - Use
scopeto build dashboards (performance ofZOOM, frequency ofMARKER). - Collapse bursts by looking at
duplicateCount.
Operation Correlation (operationId roadmap)
Upcoming enhancement: navigation methods (goTo, multi‑phase flyTo) and related map interactions will emit a generated operationId that stays constant across all log entries and intermediate viewpoint changes for that operation. This enables:
- Grouping performance metrics (start → end duration)
- Attaching analytics/events to a single user intent
- Multi-platform tracing (native + JS)
Until released, placeholder field may be null or omitted. Code relying on it should degrade gracefully. 4. Correlate sequences or future operationId for multi‑phase animations (flyTo).
Debug Tips
| Symptom | Cause | Fix |
| -------------------------- | ------------------------------------------- | -------------------------------------------------------- |
| Many duplicateCount > 10 | Tight loop / redundant state updates | Throttle calling code or suppress identical triggers. |
| Missing scope | Location lacks a prefix or non‑domain level | Add meaningful where parameter in native/js log calls. |
| Platform shows wrong value | JS fallback heuristics fired | Ensure native emission includes platform. |
Enabling Debug Logs
Set the debugLogs prop to true; debug messages (internals, clamp decisions, LOD readiness) appear only when enabled.
<EsriReactNativeView debugLogs onLog={handleLog} />Redaction & Truncation
Sensitive values (API keys, tokens) are redacted natively showing only a short prefix.
import React, { useState, useCallback } from "react";
import {
EsriReactNativeView,
MarkerData,
MapTapEvent,
MarkerDragEvent,
} from "esri-react-native";
export default function InteractiveMap() {
const [markers, setMarkers] = useState<MarkerData[]>([
{
id: "marker-1",
coordinate: { latitude: 40.7128, longitude: -74.006 },
color: "red",
draggable: true,
title: "Draggable Marker",
},
{
id: "marker-2",
coordinate: { latitude: 40.7589, longitude: -73.9851 },
color: "blue",
draggable: false,
title: "Fixed Marker",
},
]);
const handleMapTap = useCallback((event: MapTapEvent) => {
console.log("Tapped at:", event.coordinate);
// Add new marker at tapped location
const newMarker: MarkerData = {
id: `marker-${Date.now()}`,
coordinate: event.coordinate,
color: "green",
draggable: true,
};
setMarkers((prev) => [...prev, newMarker]);
}, []);
const handleMarkerDrag = useCallback((event: MarkerDragEvent) => {
console.log("Marker dragged:", event.marker);
// Update marker position in state
setMarkers((prev) =>
prev.map((marker) =>
marker.id === event.marker.id ? event.marker : marker,
),
);
}, []);
return (
<EsriReactNativeView
style={{ flex: 1 }}
apiKey="YOUR_ARCGIS_API_KEY"
markers={markers}
onMapTap={handleMapTap}
onMarkerDragEnd={handleMarkerDrag}
initialCenter={{ latitude: 40.7128, longitude: -74.006 }}
initialScale={50000}
/>
);
}Props
| Prop | Type | Description |
| ------------------ | --------------------------------------- | -------------------------------------- |
| apiKey | string | ArcGIS API key for authentication |
| bearerToken | string | Bearer token for custom authentication |
| showUserLocation | boolean | Show user's current location on map |
| autoPanMode | 'off' \| 'recenter' \| 'navigation' | Auto-pan behavior for location |
| initialCenter | {latitude: number, longitude: number} | Initial map center coordinates |
| initialScale | number | Initial map scale (zoom level) |
| minZoomLevel | number | Minimum zoom level (Mapbox style) |
| maxZoomLevel | number | Maximum zoom level (Mapbox style) |
| vectorTiles | VectorTile[] | Array of vector tile layer definitions |
| allowedHosts | string[] | Allowed hosts for network requests |
| markers | MarkerData[] | Array of markers to display on map |
| onMapTap | (event: MapTapEvent) => void | Callback when map is tapped |
| onMarkerDragEnd | (event: MarkerDragEvent) => void | Callback when marker is dragged |
Type Definitions
interface VectorTile {
id: string;
url: string;
minZoom?: number;
maxZoom?: number;
reference?: boolean;
params?: Record<string, string>;
}
interface Coordinate {
latitude: number;
longitude: number;
}
interface MarkerData {
id: string;
coordinate: Coordinate;
color?: string; // Hex color or CSS color name
draggable?: boolean;
title?: string;
subtitle?: string;
}
interface MapTapEvent {
coordinate: Coordinate;
}
interface MarkerDragEvent {
marker: MarkerData; // Updated marker with new coordinates
}Documentation
- API_REFERENCE.md: props, events, imperative handle, error codes.
- docs/contracts.md: frozen cross-platform contracts (defaults, event payloads, error codes, Bearer rules).
- CHANGELOG.md: release notes (Keep a Changelog).
docs/sdk-notes.md: every ArcGIS SDK API the library relies on, with its verified signature.docs/parity-checklist.md: manual checks used to validate native changes on iOS and Android.
Contributing
See CONTRIBUTING.md for the local setup, the project rules (pure core/ logic with shared fixtures, thin native views, verified SDK APIs) and the pull request checklist. This project follows the Code of Conduct.
Security
Report vulnerabilities privately as described in SECURITY.md.
License
MIT © 2025-2026 Victor Corral. The ArcGIS Maps SDK is licensed separately by Esri; you need your own API key and, optionally, a license string.
