spoiler alert: It is possible! And if you are using react-native-reanimated in your app, it may be the culprit...
Table of Contents
1. Preface
2. The Error in Question
3. The Broken Implementation
4. Discovery
5. Solution #1
6. Solution #2
7. Solution Trade Offs
8. TL;DR
9. About Jobber
10. Footnotes and References
NOTE: If you don't want to read this entire blog post, jump to the TL;DR.
1. Preface
So, your team is creating some form of mapping software in React Native, and you've decided to leverage react-native-maps (RNM) to seamlessly implement Google Maps (GM) on iOS and Android applications. As with any mapping software worth its salt, you want to have custom map markers - with RNM that's easy, right? Unfortunately, you might find this isn't the case.
After creating a custom React Native (RN) component to use as a child of RNM's <Marker />
component, you add it to your map view as per the RNM documentation, and you boot your iOS simulator to see your custom pins. Alas, there is a chance you are going to encounter this error:
Exception thrown while executing UI block: * -[__NSDictionaryM setObject:forKeyedSubscript:]: key cannot be nil
This is the scenario that my team and I found ourselves in. In this blog post I will outline the two solutions we developed that enabled us to use custom pins with GM on iOS in RN, using RNM. It is worth noting that I haven't ascertained the true root cause of this issue, however, I will update this blog if that changes.
2. The Error in Question
Exception thrown while executing UI block...
Like many other software errors it's not immediately clear what the problem is. Surprisingly, a Google search of the error doesn't return many results (~94 at the time of writing).
There exist a handful of unanswered Stack Overflow questions and a few issues that have been posted to the RNM GitHub repository, almost all of which invariably reference the largest thread on this error I have found.
Reading the limited resources on this issue, you might see answers such as:
The
<Marker />
component can't have children.You need to use Apple Maps as the provider for iOS and Google Maps as the provider for Android.
The native implementation of Google Map's SDK and Apple's MapKit are incompatible, so using custom markers will cause React Native to break.
Or my personal favourite, courtesy of ChatGPT:
While using
provider={PROVIDER_GOOGLE}
, you can not use custom pins on iOS with react-native-maps.
To generalize, it seemed as though the consensus was that you couldn't use custom pins with GM on iOS. The most commonly recommended and accepted solution is using the respective map providers for both platforms. This was something our team wanted to avoid, as to provide a consistent experience to our customers across platforms.
Through failure and exploration our team found two solutions for this error1, and I hope that by writing this I am able to contribute to the discussion and help others overcome this error2 in their own projects.
3. The Broken Implementation
To provide context, our code looked something like this when we encountered the error3.
Note: To make this easy to copy and paste for testing, I have attempted to make these code snippets as generic as possible, with limited dependencies.
Our custom marker component
interface CustomMarkerProps {
label: string;
}
// Using text as a marker for simplicity. It has the same behaviour (with respect to the error) as an SVG or Icon component.
export function CustomMarker(props: CustomMarkerProps): JSX.Element {
return (
<View>
<Text>{props.label}</Text>
</View>
);
}
Our component that uses React Native Reanimated (RNR)
For context, this component will render over the map (absolutely positioned) telling the user that there are no markers on the map. It is built with RNR. It fades in from the top of the screen, ultimately coming to rest 50 pixels below the top of the screen.
export function ReanimatedBubble(): JSX.Element {
const animatedStyle = useAnimatedStyle(() => {
return {
top: withTiming(50, {
duration: 100,
easing: Easing.bezier(...EASE_CUBIC_IN_OUT),
}),
};
}, []);
return (
<Animated.View entering={FadeInUp} exiting={FadeInOut} style={animatedStyle}>
<View>
<Text>{"There are no markers on the map"}</Text>
</View>
</Animated.View>
)
}
Our map view component
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
height: "100%",
width: "100%",
justifyContent: 'flex-end',
alignItems: 'center',
},
map: {
...StyleSheet.absoluteFillObject,
},
});
type MarkerType {
id: number;
coords: {
latitude: number;
longitude: number;
};
}
export function Map(): JSX.Element {
const [markers, setMarkers] = useState(generateRandomMarkers());
function generateNewMarkers() {
setMarkers(generateRandomMarkers())
}
return (
<View style={styles.container}>
<MapView
style={styles.map}
provider={PROVIDER_GOOGLE}
region={{
latitude: 55.05,
longitude: -90.32,
latitudeDelta: 12.5,
longitudeDelta: 35,
}}>
>
{markers.map((marker, index) => (
<Marker key={index} coordinate={marker.coords}>
<CustomMarker label={marker.id} />
</Marker>
))}
</MapView>
{markers.length === 0 && (
<ReanimatedBubble />
)}
<Button
title="PRESS ME TO CHANGE MARKERS"
onPress{() => {
generateNewMarkers();
}}
/>
</View>
)
}
// returns an array with [0, 100] markers
function generateRandomMarkers(): MarkerType[] {
const randInt = Math.floor(Math.random() * 101);
const markers = [];
for (let i=0; i < randInt; i++) {
const marker = {
id: i,
coords: generateRandomCoords(),
}
}
}
// returns random coordinates within Canada
function generateRandomCoords() {
const MIN_LATITUDE = 42.33173;
const MAX_LATITUDE = 67.770579;
const MIN_LONGITUDE = -125.004;
const MAX_LONGITUDE = -55.6362603;
const latitude = MIN_LATITUDE + (MAX_LATITUDE - MIN_LATITUDE) * Math.random();
const longitude =
MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * Math.random();
const coords = {
latitude,
longitude,
};
return coords;
}
Using the code snippets above, on initial render of the map, we see the following on an iOS simulator:
So far, so good! We have custom markers. Now, having the markers change is a critical piece of functionality.
I tap the "PRESS ME TO CHANGE MARKERS BUTTON" to see new data being visualized on the map...
The application crashes, we have hit the error.
4. Discovery: how we approached the problem
After encountering, then researching this error, one comment stood out to us.
You cannot render custom markers on iOS while using
provider={PROVIDER_GOOGLE}
.
With the broken implementation we had, we recognized this as a rather glaring contradiction. We did render custom markers on the map's initial render, therefore it's possible. So what's going wrong?
In some of the online threads, there is mention of developers running into this error only after they had started using RNR in their preexisting map applications, or after version changes of RNR.
Furthermore, the error states key cannot be nil
. So there is somewhere in our implementation where we were dropping keys - in our case, the indices in the marker array we iterate over in the map view.
These two ideas led us to two possible solutions we wanted to test:
- Is RNR breaking our map somehow?
- Can we manage the marker array in such a way that we don't drop indices, thus avoiding the error?
5. Solution #1: RNR is breaking the map, let's fix it
From what we had seen online, we had a hunch that RNR may be breaking our app.
To confirm this, we created an empty RN project to start from scratch. We created a proof of concept map to run on iOS that was essentially the same as what you see in the above code snippets, but without RNR or any animated components. RNM was the only library we added to the project, and the app was able to render custom pins that changed in a random fashion - confirming that it is, in fact, possible.
To demonstrate it was RNR creating the conflict, we added the animated components into the app with the smallest incremental changes, rebuilding the iOS simulator after each step.
If you'd like to try this, you can try the following:
- Install RNR
- Create the most rudimentary animated component (i.e. text wrapped in an animated view), but don't render it with your map view.
- Add your animated component to your map.
- Piece by piece, use more of the API's methods for a more complex animated component
In doing this, we were surprised to learn that it was only specific methods within RNR that threw the error. In our case specifically, two props were to blame:
1. entering={FadeInUp}
2. exiting={FadeInOut}
To remove those props while preserving the animated functionality of the "no markers" component and solve the error, we refactored as follows:
export function ReanimatedBubble(): JSX.Element {
const heightPadding = 50;
const top = useSharedValue(0);
const opacity = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => {
return {
top: withTiming(top.value, {
duration: 100,
easing: Easing.bezier(...EASE_CUBIC_IN_OUT),
}),
opacity: withTiming(opacity.value, {
duration: 100,
easing: Easing.bezier(...EASE_CUBIC_IN_OUT),
}),
};
}, []);
useEffect(() => {
top.value = heightPadding;
opacity.value = 1;
}, []);
return (
<Animated.View style={animatedStyle}>
<View>
<Text>{"There are no markers on the map"}</Text>
</View>
</Animated.View>
)
}
On the iOS simulator with these changes, the map now works as expected!
6. Solution #2: buffer the marker array
Given the error is complaining about keys being nil, we investigated what key(s) were becoming nil and when. Looking at our custom marker component, we're using the indices of the marker objects within the array as the key
prop in the <Marker />
component.
In testing how changes to the marker object array affects the keys being used to render the markers on the map, we discovered the following:
You can render an array of
n
objects as custom markers on the initial load of the map
This makes sense, we knew this from the beginning!
Changing the array such that
n' >= n
does not crash the app.
Again, given the error, this makes sense. In this case we aren't dropping any keys previously used for the markers. Adding additional markers isn't an issue.
Changing the array such that
n' < n
crashes the app
Ah ha! This confirms that it is in fact the array indices used as keys for the markers that are crashing the app when they don't exist anymore.
In other words, custom markers can change when the marker data changes, but only when the new set of markers is equal to or greater in length than the previously rendered marker array. If the new array is shorter, you will hit the error because marker indices that once existed become undefined, therefore some keys are nil.
To confirm it was the marker array indices used as keys becoming undefined we used two different methods.
We used the timeless debugging method of console logging. When mapping over the marker array in the
<MapView />
we logged the indices being used. Sure enough, when the marker array decreased in length we saw undefined being console logged for missing marker objects, and the app crashed.As a more methodical approach - we used Flipper. Adding breakpoints within the
map(() => {})
sequence, we went through the iteration step by step, looking at what data was being used. Again, in the instance of marker objects being removed from the array, we saw undefined keys and the app crashed.
To fix this, we managed the array of marker objects such that it had a constant size.
Firstly, we made a hook that looks something like this:
const BUFFERED_MARKER_ARRAY_SIZE = 100;
const BUFFER_MARKER = {
id: -1,
coords: {
latitude: -91, // this is not in the domain of latitudes, i.e. it won't appear on the map
longitude: -181, // this is not in the domain of longitudes, i.e. it won't appear on the map
},
}
const bufferedMarkerArray = Array.from(
{ length: MARKER_ARRAY_BUFFER_SIZE },
() => PLACE_HOLDER_MARKER,
);
export function useBufferedMarkers(markers: MarkerType[]): MarkerType[] {
const markerRef = useRef(bufferedMarkerArray);
useEffect(() => {
markers.forEach((marker, index) => {
markerRef.current[index] = marker;
});
}, [markers]);
return markerRef.current
}
Then, we use this hook in our <MapView />
component:
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
height: "100%",
width: "100%",
justifyContent: 'flex-end',
alignItems: 'center',
},
map: {
...StyleSheet.absoluteFillObject,
},
});
type MarkerType {
id: number;
coords: {
latitude: number;
longitude: number;
};
}
export function Map(): JSX.Element {
const [markers, setMarkers] = useState(generateRandomMarkers());
function generateNewMarkers() {
setMarkers(generateRandomMarkers())
}
const bufferedMarkers = useBufferedMarkers(markers);
return (
<View style={styles.container}>
<MapView
style={styles.map}
provider={PROVIDER_GOOGLE}
region={{
latitude: 55.05,
longitude: -90.32,
latitudeDelta: 12.5,
longitudeDelta: 35,
}}>
>
{bufferedMarkers.map((marker, index) => (
<Marker key={index} coordinate={marker.coords}>
<CustomMarker label={marker.id} />
</Marker>
))}
</MapView>
{markers.length === 0 && (
<ReanimatedBubble />
)}
<Button
title="PRESS ME TO CHANGE MARKERS"
onPress{() => {
generateNewMarkers();
}}
/>
</View>
)
}
On the iOS simulator with these changes, the map now works as expected!
7. Solution Trade Offs
Removing RNR is surely a less than perfect solution for most developers. Animation is becoming increasingly important as a standard in consumer grade applications, and users now expect it.
I hope to learn more about the root cause of the conflict between markers and RNR, and can hopefully update this post with a more clear answer. Better yet, this issue can probably be resolved within RNR itself, making this issue a bad memory of the past.
With enough trial and error though, you should be able to find work arounds within your animated components rendered with your map to bypass this issue, while keeping those silky smooth user experiences.
Let me first say that my team implemented Solution #1 within our app. We made solution 2 along with solution 1 as PoC's, to then evaluate and decide what would work best for our use case. The example code I gave for solution 2 is valid, however it has some obvious shortcomings (with respect to the code example presented within this blog):
It has a higher time and space complexity4
As written, it has a ceiling for the possible amount of marker objects.
Admittedly, I didn't bother optimizing the given example, as it serves its purpose. Some potential improvements could look something like this...
Have the buffer array in the hook match the size of your marker array, such that n_marker === n_buffer
. If n_marker
ever decreases in length, maintain the longest length of n_buffer
by inserting buffer marker objects in place of the absent markers. By doing this, you are not limited with respect to the amount of markers you can have and you're not dropping keys. However, you will have more data persisting, which could lead to performance issues.
Additionally, with the use of useEffect()
in the buffer hook, the data being used for the markers will exist outside of a component's typical life cycle. This again has some performance considerations, and you may want to be wary of memory leaks. To mitigate this, you could enhance the hook to manage buffer data when the map component is mounted and unmounted. Rather than digging into this here, I'll point you to this blog post by Caelin Sutch.
If you and your team implement something akin to solution 2, and it is much better than what I have outlined here, please let me know! I can update this blog post5, or point readers to any material you may produce.
Regardless of which solution you may choose to implement, if you're using custom markers you're going to want to be mindful of performance. This blog post by Eli Bucher covers performant map pins well, and provides a good solution.
8. TL;DR
If you are getting the error Exception thrown while executing UI block: * -[__NSDictionaryM setObject:forKeyedSubscript:]: key cannot be nil
while trying to use custom markers with Google Maps on iOS via react-native-maps, it is possible the root cause is a conflict with react-native-reanimated.
I don't know exactly what the conflict is, however, I have two solutions that may work for you!
If feasible, you can remove react-native-reanimated from your project and that should resolve the issue for you.
If that isn't an option, I would encourage you to rebuild your map in a test project without reanimated, and implement your map so that you are using custom markers (and it should work). Then, reintroduce reanimated into that project, and add your desired functionality in piece wise until you can isolate what method in reanimated is causing the error. In our case, it was our use of FadeInUp
and FadeInOut
. Your mileage may vary, but you should be able to find alternative animation solutions while preserving your app's behaviour.
Agnostic of any react-native-reanimated conflicts, this error is thrown because the indices from the marker array you're mapping over are being dropped in the case that the marker array length, n
, is changing such that n' < n
.
You can create a hook that will always return a constant sized array containing marker objects to your map view, so that it may map your objects to custom markers. For every index in this array, there will exist either a real marker object that renders on the map, or a buffer object that will not render on the map. Either way, the indices being used as keys will always exist, therefore you will not hit this error.
9. About Jobber
Our awesome Jobber technology teams span across Payments, Infrastructure, AI/ML, Business Workflows & Communications. We work on cutting edge & modern tech stacks using React, React Native, Ruby on Rails, & GraphQL.
If you want to be a part of a collaborative work culture, help small home service businesses scale and create a positive impact on our communities, then visit our careers site to learn more!
Footnotes
Take that ChatGPT, you can't steal our jobs just yet!
All of the discussion online regarding this issue has been extremely helpful, and I recommend digging into it. While we did not find the answer we were looking for, it provided a solid foundation to dig deeper into the libraries and come up with a solution that worked for us. Thank you to everyone who has contributed to this issue!
These examples are heavily abstracted. I attempted to demonstrate what the broken code looks like in as simple of form as possible.
Asymptotically, both time and space are in the domain of, at minimum,
2n
, therefore a O(n) in complexity on the RNR implementation side of things, not considering the internal's of RN or any libraries used. While this doesn't seem bad, anecdotally, our team has noticed RN performance issues which I imagine would show themselves here.And give due credit, of course!
Top comments (0)