@trackunit/react-map
v0.1.33
Published
Provider-agnostic, hook-first map library for React applications. Switch between Google Maps and Mapbox without touching your application code.
Keywords
Readme
@trackunit/react-map
Provider-agnostic, hook-first map library for React applications. Switch between Google Maps and Mapbox without touching your application code.
Design principles
- Provider behind an adapter. Google Maps or Mapbox is passed in as adapter config. Swapping providers is a one-line change; your components, events, and tests are untouched.
- Hook-first, TanStack-style. State, actions, and the renderable component are co-located at the call site via
useMap(adapter). No context tree to set up; no provider-specific types leaking into your code. - Resolver props — you decide. Where choices are genuinely the consumer's — which markers render as DOM vs symbol, what style a hovered shape gets — the library hands you a fully-described context object and takes a small decision back. It never makes the heuristic call behind your back.
- Defaults, but always composable. Every resolver ships a sensible default that is exported for composition. Use it as-is, wrap it, or replace it entirely.
- Atomic components.
MapMarker,ClusterMarker,MapMarkerIcon, andPanelare usable stand-alone, even outside a map. - Strong types. Discriminated unions, exhaustive switches, and typed resolver contracts catch mistakes at compile time.
- No vendor leakage. All provider-specific types and APIs belong inside adapters. Nothing outside an adapter may import from
mapbox-glor@vis.gl/react-google-maps. - Self-contained, portable layers. A well-designed layer hook owns its own data loading, controls, and settings so it can be dropped into any map without surrounding setup. Concretely, layers should accept a pre-built
filtersobject as a prop rather than fetching raw data — the hook owns the query internally and the caller owns the filter shape.
Three layers of abstraction
The library is intentionally split into three tiers. Reach for the highest-level tier that satisfies your needs, and only drop down a layer when you need more control. Starting at a lower tier than necessary means reinventing abstractions the higher tiers already provide.
Tier 1 — useMap and MapApi
The foundation. Full control over camera, events, controls, panels, annotations, and the adapter surface. Maximum flexibility; you invent everything yourself.
Use this when you are building something structurally novel or need to escape a constraint in a higher-level hook.
Tier 2 — Layer hooks (useMarkers, useShapes, …)
The everyday workhorse. Layer hooks give a high-productivity path to common map features without giving anything away. They surface full library behaviour with sensible defaults you can override one decision at a time via resolver props.
The atomic building blocks these compose are exported for reuse too: the output components (MapMarker, ClusterMarker, MapMarkerIcon) and helper hooks (useAdaptiveMarkerHelpers, useShapeLabelHelpers, …) that surface the default resolver heuristics. This is where almost all map feature work should live.
Tier 3 — Your domain layer hooks
The top tier is your own domain hooks — built on top of Tier 2 primitives. Highly opinionated hooks that own data fetching, display logic, controls, and interaction end-to-end. Closed by design — no customization knobs. If you need something similar but with different behaviour, drop to Tier 2 and compose from the atomic pieces.
Every finished, map-ready layer is a Tier 3 hook, so the tier you pick is really your starting point: use a ready-made domain hook as-is, or assemble your own from Tier 2 primitives.
This library exports generic primitives only. Domain hooks belong in the domain library that serves them — not in @trackunit/react-map. Trackunit's own asset and site layer hooks are reference implementations that will be published separately. If you build a domain layer that would be valuable beyond your own app, consider publishing it as its own package too.
Installation
npm install @trackunit/react-mapMap providers live in separate adapter packages — install whichever one(s) you use alongside it:
# Google Maps
npm install @trackunit/react-map-adapter-google
# Mapbox
npm install @trackunit/react-map-adapter-mapboxAlways import providers from the adapter package, never from @trackunit/react-map:
- Google Maps:
@trackunit/react-map-adapter-google(googleMapsAdapter,GoogleApiProvider, …) - Mapbox:
@trackunit/react-map-adapter-mapbox(mapboxAdapter, …)
Usage
Google Maps
import { useCameraState, useMap } from "@trackunit/react-map";
import { googleMapsAdapter } from "@trackunit/react-map-adapter-google";
const MyMap = () => {
const [Map, api] = useMap(googleMapsAdapter({
apiKey: process.env.GOOGLE_MAPS_API_KEY,
theme: "light",
language: "en",
}));
const { center, zoom } = useCameraState(api);
return (
<div className="relative h-full">
<p>Center: {center[0]}, {center[1]}</p>
<p>Zoom: {zoom}</p>
<Map className="h-full w-full">
{/* Children render inside the map */}
</Map>
</div>
);
};Mapbox
import { useCameraState, useMap } from "@trackunit/react-map";
import { mapboxAdapter } from "@trackunit/react-map-adapter-mapbox";
const MyMap = () => {
const [Map, api] = useMap(mapboxAdapter({
accessToken: process.env.MAPBOX_ACCESS_TOKEN,
theme: "dark",
language: "en",
}));
const { center, zoom } = useCameraState(api);
return (
<div className="relative h-full">
<p>Center: {center[0]}, {center[1]}</p>
<p>Zoom: {zoom}</p>
<Map className="h-full w-full">
{/* Same children work with both Google and Mapbox! */}
</Map>
</div>
);
};API
useMap(adapter)
Returns a tuple [Map, api]:
Map- React component to render the mapapi.state- Map status (isReady, initializationFailed, appearance, rasterResolution)useCameraState(api)- Reactive camera state (center, zoom, bounds, isIdle — changes at up to 60fps)api.actions.setCenter(position)- Set map center (returns promise)api.actions.setZoom(zoom)- Set zoom level (returns promise)api.actions.zoomBy(delta)- Adjust zoom by delta (returns promise)api.actions.fitBounds(bounds, options)- Fit map to bounds (returns promise)api.actions.panTo(position)- Pan to position with animation (returns promise)api.on(event, handler)- Subscribe to map events (returns unsubscribe function)
Coordinates
All coordinates use GeoJSON format: [longitude, latitude]
const berlin: GeoJsonPosition = [13.405, 52.52]; // [lng, lat]Bounding Boxes
All bounding boxes use GeoJSON format: [minLng, minLat, maxLng, maxLat]
const europeBounds: GeoJsonBbox = [-10, 35, 40, 70];Map Actions
All actions return promises that resolve when the map finishes moving:
await api.actions.setCenter([13.405, 52.52]);
await api.actions.setZoom(12);
await api.actions.fitBounds([-10, 35, 40, 70], { padding: 50, maxZoom: 15 });Map State
api.state contains low-frequency MapStatus (readiness, initialization failure, appearance, raster resolution). For CameraState that changes during panning/zooming, use useCameraState(api):
// Initialization state — rarely changes
const { isReady, appearance } = api.state;
// Camera state — changes at up to 60fps during panning
const { center, zoom, bounds, isIdle } = useCameraState(api);
// Only re-renders when zoom changes (opt-in)
const { zoom } = useCameraState(api);
useEffect(() => {
console.log("Zoom changed:", zoom);
}, [zoom]);Map Events
Subscribe to map events with type-safe handlers:
useEffect(() => {
const unsubscribe = api.on("click", (e) => {
console.log("Clicked at:", e.position);
});
return unsubscribe;
}, [api.on]);Available events:
idle- Map finished movingmovestart- Map started movingmoveend- Map finished moving (includes final state)click- Map clicked (includes position and original event)
Switching Providers
To switch providers, only change the adapter configuration:
// Before: Google Maps
const [Map, api] = useMap(googleMapsAdapter({ apiKey: "..." }));
// After: Mapbox
const [Map, api] = useMap(mapboxAdapter({ accessToken: "..." }));
// Everything else stays the same!Configuration Options
Google Maps Config
type GoogleMapsConfig = {
apiKey: string; // Required
theme?: "light" | "dark"; // Default: "light"
language?: string; // Map labels language
initialViewport?: InitialViewport; // Initial view: center+zoom or bounds
restrictBounds?: GeoJsonBbox | null; // Default: no restriction; null = no restriction
};Mapbox Config
type MapboxConfig = {
accessToken: string; // Required
theme?: "light" | "dark"; // Default: "light"
language?: string; // Map labels language
initialViewport?: InitialViewport; // Initial view: center+zoom or bounds
restrictBounds?: GeoJsonBbox | null; // Default: no restriction; null = no restriction
};restrictBounds controls panning/zoom bounds. GeoJSON format: [minLng, minLat, maxLng, maxLat].
undefined(default): no restriction (infinite horizontal scroll)null: no restriction (infinite horizontal scroll)GeoJsonBbox: restrict to custom region (e.g. Europe:[-10, 35, 40, 70])
Invalid bboxes are validated with geoJsonBboxSchema; on failure, a console warning is emitted and no restriction is applied (fallback to null).
InitialViewport is a discriminated union:
{ type: "center"; center: [lng, lat]; zoom?: number }— center with optional zoom{ type: "bounds"; bounds: GeoJsonBbox; padding?: number; maxZoom?: number }— fit to bounds
Accessibility
Maps include built-in accessibility features:
- ARIA labels and roles
- Keyboard navigation:
- Arrow keys: Pan map (100px)
- +/-: Zoom in/out (1 level)
- Home: Reset to default view
- Tab: Focus map
- Escape: Deselect/close popups
Architecture
@trackunit/react-map is built in independently-useful layers: provider adapters, the useMap foundation and MapApi, layer hooks (useMarkers, useShapes, …), and atomic components (MapMarker, ClusterMarker, …). Each tier is usable and testable on its own — see the design principles and three-tier model above.
Provider integrations ship as separate packages:
@trackunit/react-map-adapter-google— Google Maps@trackunit/react-map-adapter-mapbox— Mapbox
Adding New Providers
To add a new map provider:
- Create adapter instance implementing
AdapterInstance<TConfig> - Create type converters (GeoJSON ↔ provider types)
- Create renderer component
- Create adapter factory using
defineAdapter() - Export from
index.ts
Example structure:
src/adapters/leaflet/
├── leafletAdapter.ts
├── LeafletAdapterInstance.ts
├── LeafletRenderer.tsx
├── leafletTypeConverters.ts
└── constants.tsFor more info and a full guide on Iris App SDK Development, please visit our Developer Hub.
Trackunit
This package was developed by Trackunit ApS. Trackunit is the leading SaaS-based IoT solution for the construction industry, offering an ecosystem of hardware, fleet management software & telematics.
