Mapbox feature-state: hover highlighting without reloading your data
Feature-state restyles a feature on hover without resending geometry — but it silently does nothing when features have no id. Here's the mechanism, a worked pattern, and the trade-offs.
04 Mar 2026, 00:02 UTC

The hover effect that does nothing
You add a fill layer, wire up a mousemove handler, and nothing changes color. Or worse: it works, but the map stutters because every pointer movement calls setData on a source holding tens of thousands of polygons. Both symptoms usually trace back to the same design decision — how you represent per-feature interaction state.
Mapbox GL JS has a purpose-built mechanism for this: feature-state. It attaches transient render state to individual features so layer paint properties can read it, without resupplying geometry. The catch is that it only works when features have stable identity, and when the layer's paint actually references the state. Miss either one and the failure is silent.
What feature-state actually is
Feature-state is a small key/value store scoped to a source. You write to it with setFeatureState, read it inside style expressions with ['feature-state', 'key'], and clear it with removeFeatureState. Nothing about the underlying data changes — the renderer just re-evaluates paint for the affected features.
That last point matters. State only takes effect where a paint property uses an expression that references it. A layer with a static 'fill-color': '#9aa4b2' will ignore state entirely, no matter how many times you call setFeatureState.
The identity requirement
Every feature you want to style must have an id. GeoJSON features can carry a top-level numeric id, or you can promote a string property to id at source creation with promoteId. Features without an id cannot hold state — this is the most common reason a hover highlight appears broken.
For vector tile sources the same rule applies, but the ids have to exist inside the tiles. Moving a dataset from client-side GeoJSON to vector tiles therefore changes the identity model; check your tiling pipeline's documentation for how it emits feature ids.
A worked hover pattern
Run this in the browser context where your map instance lives. The map needs whatever access token and style your project already uses; token requirements and pricing are commercial terms that change, so confirm them against current documentation rather than assuming.
map.on('load', () => {
map.addSource('parcels', {
type: 'geojson',
data: '/data/parcels.geojson',
promoteId: 'parcel_id' // string property becomes the feature id
});
map.addLayer({
id: 'parcels-fill',
type: 'fill',
source: 'parcels',
paint: {
'fill-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#1f6feb',
'#9aa4b2'
],
'fill-opacity': 0.7
}
});
});
The handler keeps exactly one feature highlighted at a time. e.features[0].id is the promoted id, not an array index.
let hoveredId = null;
map.on('mousemove', 'parcels-fill', (e) => {
const id = e.features[0].id;
if (id === hoveredId) return;
if (hoveredId !== null) {
map.removeFeatureState({ source: 'parcels', id: hoveredId }, 'hover');
}
hoveredId = id;
map.setFeatureState({ source: 'parcels', id }, { hover: true });
});
map.on('mouseleave', 'parcels-fill', () => {
if (hoveredId !== null) {
map.removeFeatureState({ source: 'parcels', id: hoveredId }, 'hover');
hoveredId = null;
}
});
Expected check: moving the pointer across polygons recolors one at a time, and the color reverts when the pointer leaves. If nothing recolors, first log e.features[0].id. If it is undefined, your promoteId property name doesn't match the data, or the features carry no id at all.
Pointer handlers fire often. Querying rendered features on every event has a cost, and throttling or debouncing is a normal mitigation — but the right threshold depends on dataset size and device, so measure rather than copying a number from a blog post.
Trade-offs and where it stops working
- Ephemeral by design. Feature-state is client-side render state. It isn't persisted, isn't shared between maps or users, and is discarded when the source data is replaced. It suits hover and selection feedback, not durable application state.
- Scoped per source. The same logical feature present in two sources needs two separate state entries.
- Not for raster. Feature-state applies to vector tile and GeoJSON sources; raster sources don't support it.
- Silent failure modes. Missing ids and static paint values both produce no error — just no visible change.
The alternative, calling setData with modified GeoJSON on every hover, re-parses and re-tiles the data each time. On small datasets that's invisible; on larger ones it's a frequent source of flicker and input lag. Feature-state avoids the re-parse but adds the identity constraint. If your data has no usable id, that's a decision to make before you write the handler.
Verify it in the SDK you actually ship
Feature-state exists in Mapbox GL JS, the native Maps SDKs, and the MapLibre GL JS fork, but option names and behavior are not guaranteed identical across them. GL JS also changed to a proprietary license at v2, with MapLibre as the community fork, so the SDK you ship determines which documentation applies. Treat the following as a checklist to confirm, not a settled fact:
- Read the Style Specification entry for the
feature-stateexpression and the API reference forsetFeatureState,getFeatureState, andremoveFeatureStatein your SDK's docs. - Build a minimal map with a
promoteIdsource and a paint expression that reads state. Confirm hover works. - Remove
promoteIdand reload. The highlight should stop working — that confirms identity is the dependency, not something else in your code. - If responsiveness is the concern, build the same map with
setData-per-hover and compare on a dataset large enough to matter.
Do that once, and the next time a hover effect silently does nothing you'll know which two things to check first.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.