Mapbox GL JS: Driving Styles with Expressions Instead of Rewriting Sources
Learn how Mapbox GL JS style expressions like get and interpolate can reduce client-side re-renders by keeping source data static while updating visuals through feature state and property lookups.
12 Mar 2026, 02:23 UTC

The problem: restyling by rewriting data
If you have used Mapbox GL JS to build an interactive map, you have probably reached for setData when a feature needs to change appearance. It works, but it is heavy. Calling setData on a GeoJSON source tells the library to parse the new data, rebuild internal tile structures, and upload geometry to the GPU. When the change is purely visual—a hover highlight, a zoom-dependent radius, a category color—that machinery is wasted work.
A better approach for many styling-only changes is to keep the source static and let the style layer compute appearance from feature properties, zoom level, and feature state. Mapbox GL JS supports this through style expressions: small JSON arrays that act as functions inside paint and layout properties.
Expressions versus source updates
Style expressions are not new to Mapbox GL JS, but they are easy to overlook when a quick setData call seems simpler. An expression such as ['get', 'magnitude'] reads a property from the current feature. An expression such as ['interpolate', ['linear'], ['zoom'], 10, 2, 14, 8] scales a value continuously with zoom. Because these run in the style layer, changing a property or feature state can trigger a repaint without the source layer ever seeing new geometry.
The practical pattern is straightforward: encode your data once, then describe how that data maps to pixels. Reserve setData for genuine data changes—new points, changed coordinates, updated attributes—rather than visual state.
A worked example: proportional circles
Suppose you have a GeoJSON source of earthquake points, each with a magnitude property, and you want circle radius to reflect magnitude while staying legible at all zoom levels.
map.addSource('quakes', {
type: 'geojson',
data: 'https://example.com/quakes.geojson' // replace with your data URL
});
map.addLayer({
id: 'quake-circles',
type: 'circle',
source: 'quakes',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['get', 'magnitude'],
0, 2,
5, 10,
9, 30
],
'circle-color': [
'interpolate', ['linear'], ['get', 'magnitude'],
0, '#f7fbff',
5, '#74a9cf',
9, '#08306b'
],
'circle-opacity': 0.8
}
});
In this configuration, the source is added once. Zooming or panning does not re-parse the GeoJSON; the style layer evaluates the interpolate expressions per feature per frame. If you later need to change the visual encoding—say, to make radius proportional to depth instead—you update the layer paint properties, not the source data.
Interactive state without rewriting the source
Hover and selection are common cases where developers reach for setData with a mutated feature. Mapbox GL JS offers a lighter alternative: setFeatureState. It requires the source to define a promoteId, which tells the library which property uniquely identifies each feature.
map.addSource('quakes', {
type: 'geojson',
data: 'https://example.com/quakes.geojson',
promoteId: 'id' // required for setFeatureState
});
map.addLayer({
id: 'quake-circles',
type: 'circle',
source: 'quakes',
paint: {
'circle-radius': [
'case',
['boolean', ['feature-state', 'hover'], false],
14,
['interpolate', ['linear'], ['get', 'magnitude'], 0, 2, 9, 30]
],
'circle-stroke-width': [
'case',
['boolean', ['feature-state', 'hover'], false],
2,
0
],
'circle-stroke-color': '#000'
}
});
// On mouseover, run in your event handler:
map.setFeatureState(
{ source: 'quakes', id: feature.id },
{ hover: true }
);
Here, the feature-state expression reads state set by setFeatureState. The geometry never changes; only the paint values do. This is intended for interactive state such as hover or selected, not for bulk styling of thousands of features.
Trade-offs and limitations
Data-driven styling is not free. Complex expressions evaluated over large vector tile datasets can increase per-frame cost. If every feature requires a deeply nested interpolate with multiple stops, the style layer does more work on every frame. For very dense data, client-side expression evaluation does not replace server-side generalization or clustering.
Expression syntax is also versioned. Mapbox GL JS v1 introduced a stable expression syntax; later majors added refinements and deprecated some legacy patterns. Before deploying, verify that the functions you use—get, interpolate, feature-state—are supported by your installed version by checking the release notes for that major version.
Finally, setFeatureState is limited to sources with promoteId and is designed for transient interactive state. It is not a general-purpose property store.
How to check the result
You can validate this approach in your own application without trusting any benchmark:
- Build a minimal map with a GeoJSON source and a circle layer using
['get', 'magnitude']forcircle-radius. - Open your browser Performance panel, zoom and pan, and confirm that source parsing is not repeated on every interaction.
- Add
promoteIdand afeature-stateexpression, then toggle hover withsetFeatureStateand observe the style change. - Build a control version that calls
setDataon interaction and compare frame times in your own dataset. - Check the Mapbox GL JS release notes for your installed version to confirm expression support.
If the expression-driven version feels smoother on your data, you have a concrete reason to keep the source static. If frame times are worse with complex expressions, precomputing style properties in your data may be the simpler path.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.