DEV Community

DONG GYUN SEO
DONG GYUN SEO

Posted on

Taming OpenLayers Events in React: Why the Observer Pattern Is Your Best Friend

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);
  }
}

Enter fullscreen mode Exit fullscreen mode

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"),
      }
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

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") 
        }
      );
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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;
};

Enter fullscreen mode Exit fullscreen mode
// 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]);
};

Enter fullscreen mode Exit fullscreen mode

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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)
      );
    }
  };
};

Enter fullscreen mode Exit fullscreen mode

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  │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘

Enter fullscreen mode Exit fullscreen mode

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());

Enter fullscreen mode Exit fullscreen mode

That's it. No changes to existing code. No risk of breaking the highlight behavior or click handling.

Lessons Learned

  1. Embrace the impedance mismatch

Don't fight OpenLayers' imperative nature. Wrap it in a clean interface (EventManager) and let React consume that interface.

  1. 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.

  1. Throttle aggressively

100ms might seem slow, but users won't notice. Start aggressive and loosen if needed.

  1. Observers should be single-purpose

HighlightFeatureObserver handles highlighting. ClickFeatureObserver handles selection. Mixing concerns in one observer defeats the purpose.

  1. 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)