We were building out a new “danji” (housing complex) map page. The moment the page mounted, Kakao Maps refused to settle down. The console kept logging setCenter, and the React DevTools Profiler showed the parent component re-rendering wildly, frame after frame. The cause turned out not to be the SDK, but the way we were handing it props.
1. Diagnose
The first task was to localize where the re-renders were starting. Profiler recordings showed the same parent component committing again and again. When I diffed its props between commits, something interesting jumped out: the inner values (lat, lng) of the center prop on <KakaoMap> were always the same, but the prop object itself was a different object each time.
That was the giveaway. JavaScript compares objects by reference. Two literals like { lat: 37.5, lng: 127 } constructed on different renders are not equal under ===, even though their contents are identical. From the library’s perspective, “the center just changed again” is a perfectly reasonable conclusion.
I peeked into react-kakao-maps-sdk’s internals briefly. Its <Map> watches center and level via useEffect dependencies, calling map.setCenter() whenever it sees a new reference. That call shifts the Kakao Map instance’s bounds, which fires the onBoundsChanged callback, where we update outer state, which re-renders the parent, which produces yet another fresh object. The shape of the infinite loop became clear.
2. Reproduce
To confirm the hypothesis, I rebuilt it in isolation.
function Demo() {
const [bounds, setBounds] = useState(null)
return (
<KakaoMap
center={{ lat: 37.5, lng: 127 }} // new object every render
level={3}
onBoundsChanged={(b) => setBounds(b)}
/>
)
}
This component starts re-rendering the moment it mounts and never stops. setBounds is called, the parent re-renders, a brand-new { lat: 37.5, lng: 127 } is constructed, the SDK treats it as a new center and calls setCenter, which fires onBoundsChanged again — and the cycle repeats forever.
I then checked whether hoisting the object to a module constant breaks the loop:
const FIXED_CENTER = { lat: 37.5, lng: 127 }
function Demo() {
const [bounds, setBounds] = useState(null)
return (
<KakaoMap
center={FIXED_CENTER}
level={3}
onBoundsChanged={(b) => setBounds(b)}
/>
)
}
It does. Same reference every render means the SDK sees “no change,” and rendering settles. Hypothesis confirmed.
3. Fix
In real code, initialStatus was a dynamic prop, so I couldn’t just hoist it as a module constant. Instead, I froze the value at mount, exactly once.
// before
function SaveHomeMap({ initialStatus, ... }) {
return (
<KakaoMap
center={{ lat: initialStatus.lat, lng: initialStatus.lng }}
level={initialStatus.zoom}
onBoundsChanged={updateBounds}
>
...
</KakaoMap>
)
}
// after
function SaveHomeMap({ initialStatus, ... }) {
const [initialCenter] = useState(() => ({
lat: initialStatus.lat,
lng: initialStatus.lng,
}))
const [initialLevel] = useState(() => initialStatus.zoom)
return (
<KakaoMap
center={initialCenter}
level={initialLevel}
onBoundsChanged={updateBounds}
>
...
</KakaoMap>
)
}
A useState initializer runs once on mount and is never re-evaluated afterward. So initialCenter keeps a stable reference forever. After mount, the SDK manages its own position internally. The key idea is to not two-way bind center to outside state — outside, you only say “start here,” and from then on you trust the SDK’s internal state.
useMemo would work in spirit, but useMemo reconstructs the object whenever its deps change. When you genuinely need “evaluate exactly once,” a useState initializer with no dependencies is safer.
4. Prevent recurrence
This kind of trap is sticky — once you fall into it, the next person doing similar work is likely to fall into it too. A few habits stuck.
First, never share a single prop slot between “initial value” and “current value.” Even when a library doesn’t make the distinction explicit in prop naming, decide for yourself which props are “lock once” and freeze them inside the component. For anything you want to update later, reach for the imperative APIs the library exposes (map.panTo(), map.setBounds(), etc.). Keeping the declarative prop and the imperative method in separate roles avoids almost all of these infinite-loop traps.
Second, don’t pass inline objects, arrays, or functions as props to a third-party component. Code review now includes the question: “is this prop being constructed fresh on every render?” Especially when the library uses that prop as a useEffect dependency, you’ve got a strong candidate for a future bug. Reading a tiny bit of the library’s source — just enough to know how each prop is consumed — pays for itself many times over.
Third, accept that no lint rule covers everything. ESLint plugins like react/jsx-no-constructed-context-values only flag this for Context.Provider’s value prop, not for arbitrary library props. The most practical guard turned out to be the review-time question above, plus a habit of reading library docs and source to understand how a prop is actually consumed.
The whole episode pushed me to think more carefully about where the boundary between React and an external library should sit. When you don’t know what the library is doing inside, a slightly suspicious habit toward each prop is what saves you an afternoon of debugging.