A battle-tested approach to managing map interactions without losing your sanity
The Problem Everyone Ignores
Let's be honest. If you've ever built a serious GIS application with OpenLayers and React, you know the pain. OpenLayers fires events like a machine gun—pointermove, singleclick, moveend, change:resolution—and your React components are desperately trying to keep up.
The typical approach? Scatter map.on() calls across your components. Create callback hell. Watch your application slowly become unmaintainable.
I've seen codebases where every component that touches the map has its own event listeners. Duplicated logic everywhere. Race conditions when multiple handlers fight over the same feature. Performance tanks because pointermove fires 60 times per second with no throttling.
There's a better way. And it's not some fancy library—it's a design pattern that's been around since 1994.
Why OpenLayers + React Is Actually Hard
Before diving into the solution, let's acknowledge why this combination is genuinely difficult:
1. Impedance Mismatch
OpenLayers is an imperative, object-oriented library. You create map instances, mutate them, attach event handlers. React is declarative. You describe what you want, and React figures out how to get there.
These two paradigms don't naturally play well together.
2. Event Granularity
OpenLayers gives you low-level events. A singleclick event doesn't tell you "the user clicked on a farm boundary"—it tells you "the user clicked at pixel [x, y]". You have to do the heavy lifting of figuring out what that means.
3. Multiple Consumers
In a real application, multiple features need to respond to the same event:
Highlight the feature under the cursor
Show a tooltip
Update a sidebar with feature details
Log analytics
Each of these concerns is separate, but they all need the same event data.
The Observer Pattern: Not Just Academic Theory
The Observer pattern solves exactly this problem. One subject (the map) broadcasts events to multiple observers (your feature handlers), and each observer decides independently how to respond.
Here's the core architecture I've implemented in production:
// The contract every observer must fulfill
export interface OLObserver {
onEvent(eventType: string, event: any): void;
}
// The central event coordinator
export class EventManager {
private map: OLMap;
private observers: OLObserver[] = [];
constructor(map: OLMap) {
if (!(map instanceof OLMap)) {
throw new Error("EventManager requires an OpenLayers map instance.");
}
this.map = map;
}
addObserver(observer: OLObserver): void {
this.observers.push(observer);
}
removeObserver(observer: OLObserver): void {
this.observers = this.observers.filter((obs) => obs !== observer);
}
private notifyObservers(eventType: string, event: any): void {
for (const observer of this.observers) {
observer.onEvent(eventType, event);
}
}
attachEventHandlers(): void {
const throttledPointerMove = throttle(
(event) => this.handleEvent("pointermove", event),
100
);
this.map.on("pointermove", throttledPointerMove);
this.map.on("singleclick", (event) => this.handleEvent("singleclick", event));
}
private handleEvent(eventType: string, event: any): void {
this.notifyObservers(eventType, event);
}
}
Notice what's happening here:
Single point of attachment: The map has exactly one set of event listeners, managed in one place.
Built-in throttling: pointermove is throttled to 100ms. This alone can save you from performance disasters.
Decoupled observers: Each observer only knows about the OLObserver interface. It doesn't know about other observers. It doesn't know about the map's internal state.
Concrete Observers: Where the Real Work Happens
The EventManager is just plumbing. The interesting stuff happens in the observers.
ClickFeatureObserver: Handling Feature Selection
export interface ClickFeatureFilter {
filterFn: (feature: Feature) => boolean;
callback?: (feature: Feature, event: DefaultEvent) => void;
}
class ClickFeatureObserver implements OLObserver {
private onFeatureClick: OnFeatureClick;
private filters: ClickFeatureFilter[];
private hitTolerance: number;
constructor({
onFeatureClick,
filters = [],
hitTolerance = 5
}: ClickFeatureObserverProps) {
this.onFeatureClick = onFeatureClick || (() => {});
this.filters = filters;
this.hitTolerance = hitTolerance;
}
onEvent(eventType: string, event: DefaultEvent): void {
// Early exit: we only care about clicks
if (eventType !== "singleclick") return;
const map = event.map;
map.forEachFeatureAtPixel(
event.pixel,
(feature: Feature) => {
// Filter chain: check each filter in order
if (this.filters.length) {
for (const { filterFn, callback } of this.filters) {
if (filterFn(feature)) {
callback?.(feature, event);
this.onFeatureClick(feature, event);
return true; // Stop iteration
}
}
}
this.onFeatureClick(feature, event);
return true;
},
{
hitTolerance: this.hitTolerance,
layerFilter: (layer) => !layer.get("ignoreClick"),
}
);
}
}
Key design decisions:
Filter chain pattern: Different features might need different handling. A filter chain lets you define specific behaviors for specific feature types without polluting the main callback.
hitTolerance: Clicking exactly on a 1-pixel line is impossible on touch devices. A 5-pixel tolerance makes the UX dramatically better.
ignoreClick layer flag: Some layers (measurement tools, temporary drawings) shouldn't respond to clicks. Instead of maintaining a hardcoded list, each layer self-identifies.
HighlightFeatureObserver: Visual Feedback
class HighlightFeatureObserver implements OLObserver {
private highlightedFeature: Feature | null = null;
constructor(private setStyle: boolean = true) {}
onEvent(eventType: string, event: any): void {
const map = event.map;
if (eventType === "pointermove") {
// Clear previous highlight
if (this.highlightedFeature) {
if (this.setStyle) {
this.highlightedFeature.setStyle(null); // Reset to layer default
}
this.highlightedFeature = null;
map.getTargetElement().style.cursor = "";
}
// Find and highlight new feature
map.forEachFeatureAtPixel(
event.pixel,
(feature: Feature, layer: any) => {
if (!layer) return false;
if (layer.get("ignoreClick")) return false;
this.highlightedFeature = feature;
if (this.setStyle) {
feature.setStyle(defaultHighlightStyle);
}
map.getTargetElement().style.cursor = "pointer";
return true;
},
{
hitTolerance: 3,
layerFilter: (layer) => !layer.get("ignoreClick")
}
);
}
if (eventType === "singleclick") {
map.forEachFeatureAtPixel(
event.pixel,
(feature: Feature, layer: any) => {
if (!layer) return false;
if (layer.get("isMeasurement") || layer.get("ignoreClick")) return false;
const extent = feature.getGeometry().getExtent();
const center = getCenter(extent);
map.getView().animate({
center,
zoom: 17,
duration: 500
});
return true;
},
{
hitTolerance: 3,
layerFilter: (layer) => !layer.get("ignoreClick")
}
);
}
}
}
This observer handles two related but distinct responsibilities:
Hover highlighting: Change the feature style and cursor when the mouse moves over it
Click-to-zoom: Animate the view to center on clicked features
The setStyle constructor parameter is a subtle but important escape hatch. Sometimes you want cursor changes without style changes (perhaps another system manages styles).
The React Integration Layer
Now for the tricky part: connecting this to React's lifecycle.
// useEventManager.ts
const useEventManager = (map: OLMap): EventManager | null => {
const eventManager = useRef<EventManager | null>(null);
useMemo(() => {
if (map) {
eventManager.current = new EventManager(map);
}
}, [map]);
return eventManager.current;
};
// useEventHandlers.ts
export const useEventHandlers = (
eventManager: EventManager,
ready: boolean,
onFeatureClick: OnFeatureClick
) => {
useEffect(() => {
if (!ready || !eventManager) return;
// Register observers
eventManager.addObserver(new HighlightFeatureObserver(false));
eventManager.addObserver(
new ClickFeatureObserver({
onFeatureClick,
})
);
// Start listening
eventManager.attachEventHandlers();
// Cleanup on unmount
return () => {
eventManager.cleanUp();
};
}, [eventManager, ready]);
};
Why useRef for the EventManager?
The EventManager is a long-lived object that shouldn't be recreated on every render. Using useRef ensures we create it once and maintain the reference.
Why the ready flag?
The map initialization is asynchronous. Trying to attach observers before the map exists causes errors. The ready flag gates the effect until everything is properly initialized.
Usage in Components
Here's how this comes together in a real component:
const BackgroundMap = ({
layerManager,
eventManager,
ready,
mapId,
map
}: BackgroundMapProps) => {
const [highlightEnabled, setHighlightEnabled] = useState(true);
// Highlight layer manages selected feature state
const { selectedFeature, setSelectedFeature, onFeatureClick } =
useHighlightLayer(layerManager, ready && highlightEnabled);
// Connect observers to the event manager
useEventHandlers(eventManager, ready && highlightEnabled, onFeatureClick);
return (
<div className="relative h-full w-full">
<div id={mapId} className="h-full w-full" />
{selectedFeature && (
<FeatureInfoWindow
feature={selectedFeature}
onClose={() => setSelectedFeature(null)}
/>
)}
</div>
);
};
The component doesn't know about OpenLayers events. It doesn't know about pixel coordinates or hit tolerance. It just receives selectedFeature when something is clicked and renders accordingly.
Performance: The Throttle That Saves You
Let's talk about that throttle function:
export const throttle = <T extends AnyFunction>(
func: T,
limit: number
): ((...args: Parameters<T>) => void) => {
let lastFunc: ReturnType<typeof setTimeout> | null = null;
let lastRan: number | undefined;
return function (...args: Parameters<T>): void {
if (!lastRan) {
func.apply(this, args);
lastRan = Date.now();
} else {
if (lastFunc) clearTimeout(lastFunc);
lastFunc = setTimeout(
() => {
if (Date.now() - lastRan! >= limit) {
func.apply(this, args);
lastRan = Date.now();
}
},
limit - (Date.now() - lastRan)
);
}
};
};
Without throttling, pointermove fires on every mouse movement—potentially 60+ times per second. Each event triggers forEachFeatureAtPixel, which iterates through potentially thousands of features.
A 100ms throttle reduces this to ~10 events per second. Users don't perceive the difference, but your CPU does.
The Architecture Diagram
┌─────────────────────────────────────────────────────────┐
│ React Component │
│ ┌─────────────────────────────────────────────────┐ │
│ │ useEventHandlers(eventManager, ready, callback) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ EventManager │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ pointermove │───▶│ throttle │──┐ │
│ │ (raw) │ │ (100ms) │ │ │
│ └─────────────┘ └─────────────┘ │ │
│ ┌─────────────┐ │ │
│ │ singleclick │─────────────────────┼─▶ notifyAll() │
│ └─────────────┘ │ │
└──────────────────────────────────────┼──────────────────┘
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ HighlightObserver │ │ ClickFeatureObserver │ │ YourCustomObserver │
│ ──────────────── │ │ ──────────────── │ │ ──────────────── │
│ • Cursor changes │ │ • Feature selection │ │ • Analytics │
│ • Style highlighting │ │ • Filter chains │ │ • Logging │
│ • Click-to-zoom │ │ • Custom callbacks │ │ • Whatever you need │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
Extending the System
Need to add analytics tracking? Create a new observer:
class AnalyticsObserver implements OLObserver {
onEvent(eventType: string, event: DefaultEvent): void {
if (eventType === "singleclick") {
const map = event.map;
map.forEachFeatureAtPixel(
event.pixel,
(feature: Feature) => {
analytics.track("feature_clicked", {
featureId: feature.getId(),
featureType: feature.get("type"),
coordinates: event.coordinate,
});
return true;
}
);
}
}
}
// Register it
eventManager.addObserver(new AnalyticsObserver());
That's it. No changes to existing code. No risk of breaking the highlight behavior or click handling.
Lessons Learned
- Embrace the impedance mismatch
Don't fight OpenLayers' imperative nature. Wrap it in a clean interface (EventManager) and let React consume that interface.
- Layer flags are powerful
Using layer.set("ignoreClick", true) is more maintainable than hardcoded layer lists. When you add a new utility layer, you set the flag once and the entire event system respects it.
- Throttle aggressively
100ms might seem slow, but users won't notice. Start aggressive and loosen if needed.
- Observers should be single-purpose
HighlightFeatureObserver handles highlighting. ClickFeatureObserver handles selection. Mixing concerns in one observer defeats the purpose.
- The ready flag pattern is essential
Asynchronous initialization is unavoidable with maps. Gate your effects properly or accept random crashes.
Conclusion
The Observer pattern isn't glamorous. It won't get you Twitter likes. But it transforms a tangled mess of event handlers into a clean, extensible system.
If you're building anything beyond a toy map application, invest the time in proper event architecture. Your future self—and anyone else who touches the codebase—will thank you.
The code in this article is from a production agricultural GIS platform handling real-time field data visualization. It's been battle-tested with complex layer interactions, mobile users with fat fingers, and product managers who keep asking for "just one more click behavior."
It works. And now you know how to build it yourself.
Have questions? Found a better approach? I'm always interested in how others solve this problem. The GIS + React intersection is still surprisingly underexplored.

Top comments (0)