Avoid re-rendering of open layers map when context changes - reactjs

I am using an Open Layers map inside React. I am attaching it to the DOM using setTarget like so:
const mapObject = useMap(maplayers);
useEffect(() => {
mapObject.setTarget(mapRef.current);
return () => mapObject && mapObject.setTarget(undefined);
},[mapObject]);
Inside the useMap.js hook I am writing changes to the center and zoom of the map back to the context:
const {
zoom,
setZoom,
center,
setCenter,
} = useContext(MapContext);
let mapObject = null;
var projection = new olProj.Projection({
code: 'EPSG:25832',
units: 'm',
});
let options = {
target: 'map',
controls: olControl.defaults({
rotate: false,
zoom: false,
attributionOptions: {
collapsible: false,
},
}),
layers: maplayers,
view: new ol.View({
projection: projection,
center: center,
zoom: zoom
}),
};
mapObject = new ol.Map(options);
mapObject.on('moveend', (e) => {
console.log('moveend');
setCenter(() => e.target.getView().getCenter());
setZoom(() => e.target.getView().getZoom());
});
The problem now is that the whole map gets rerendered every time the center and zoom change. How can I avoid this? The only idea I have is to use useEffect with a dependency array that misses center and zoom but I know this is bad practise and should not be done.

You just discovered why one should not use useEffect to create Openlayers components in React. I answer this question once per week here - I will probably end up writing a very long and detailed answer and then start marking those questions as duplicates. Or maybe write a tutorial for Openlayers + React.
What you are trying to achieve is not easy and no one got it right the first time. I am the author of rlayers - https://github.com/mmomtchev/rlayers - a set of components for using Openlayers in React. The only reliable way to do everything that React expects is to use class-based components and to completely rewrite the React component life-cycle. You are free to use my components - either directly, either as an inspiration for writing your own.
In your particular case, the problem is that you create a new mapObject every time the hook is called. React detects this change and decides that the component must be rerendered. Built-in React hooks work around this by memoizing the value. Your hook should declare the dependencies of the mapObject and then use React.useMemo to always return the same object:
const mapObject = React.useMemo(() => {
// code to generate the mapObject
return mapObject;
}, [ projection, url, /* and all other items that require rerender */ ]
);
This way your hook will always return the same mapObject and you will continue to use a useEffect with only the mapObject as a dependency - according to the React guidelines.

Related

Next.js: What is the best way to solve the warning about different server & client styles based on react-spring?

I'm working with Next.js and using a react-spring library to get an animation for a bottomsheet component. It works, however there is a warning appears:
Warning: Prop style did not match. Server: "transform:translate3d(0,Infinitypx,0)" Client: "transform:translate3d(0,652px,0)"
I've carefully investigated this warning and know that it's about incorrect rendering of the HTML element on the server and on the client side. It's clear that on the server side there is no viewport height and thus react-spring can't calculate normally the final value and Next.js registers it as an one value with Infinity and then blames on the client side when the value is calculated correctly due to available viewport height.
I'm wondering what is the best way to rid of this error?
Unfortunatelly I can't catch the react-spring calculation stage and to set a correct value.Tere is no API to do it and basically I just don't know the user's viewport height.
I've thinking about the using indexOf for the value and check if the Infinity presented and replace it for ex: by 0
however it still doesn't solve a problem as the final value will be different anyway.
Maybe someone has an idea or some link to docs etc. where I could find a solution for that?
Basically it's just a warning but I'd like to fix it anyway.
Here is the example code:
import { a, config, useSpring } from '#react-spring/web';
export function BottomSheet({propsHeight}) {
const finalHeight = propsHeight || height - 62;
const display = y.to((py) => (py < finalHeight ? 'flex' : 'none'));
const [{ y }, api] = useSpring(() => ({ y: finalHeight }));
const open = (dragEvent?: any) => {
const canceled = dragEvent?.canceled;
// when cancel is true, it means that the user passed the upwards threshold
// so need to change the spring config to create a nice wobbly effect
api.start({
y: 0,
immediate: false,
config: canceled ? config.wobbly : config.stiff,
});
};
const close = (velocity = 0) => {
api.start({
y: finalHeight,
immediate: false,
onResolve() {
if (onClose) {
onClose();
}
},
config: { ...config.stiff, velocity },
});
};
useEffect(() => {
// provide open/close actions to parent control
getActions(open, close);
}, []);
// pseudo hmtl. Removed all other markup to simplify things
return (<a.div
style={{
y, // Here is the problem of the server & client incorrect values
}}
/>)
}
I highly appreciate any help!
Kind Regards
The only one solution I've found so far for this use case it's rendering the component on client side only and, basically, it makes sense because this code is based on the browser API. I don't see any other possible solutions
To achieve it you can use dymanic imports and Next.js supports them well: here is the docs
You should disable the SSR in the import options and the component will be rendered on the client side only.
Like this:
import dynamic from 'next/dynamic';
const BottomSheetDynamic = dynamic(
() =>
import('#mylibrary/components/bottomsheet/BottomSheet').then(
//this component doesn't have default import so should do it in this way
(mod) => mod.BottomSheetexport
),
{
ssr: false,
}
);

In my React app, why does "setPointerCapture" not work if I am changing the z-index of my (draggable) component (that is using "setPointerCapture")?

I have discovered a very strange, undesired behavior of my React application that I would like to understand and to correct.
Unfortunately, my application is quite large and I was not able to recreate the issue I am encountering in a smaller application (for demonstration purposes), although I really did try.
Here is what I would like to achieve:
In order to control re-rendering of all my visual content represented by data, I use useRef hooks in a couple of custom hooks to represent and manipulate my data without triggering a re-render due to a changed state. Once all my data is “up-to-date” (or once it makes sense from by “business logic’s perspective”) I call requestUpdate in my main component that updates the relevant data represented by useState hooks. This data goes as props into all my components.
Now, I would like to perform a drag-and-drop operation on some visual components. In order to catch all relevant events, I call setPointerCapture on receiving an onPointerDown event. In order to properly see the components I am dragging around I adjust their z-indices in order to have the components being dragged “on top”. That works fine, by the way.
Here is the issue that I am encountering:
What does not work is the setPointerCapture if I change the z-indices. Strangely, if I comment out the respective little part of my code that changes the z-indices, everything works fine. If I don’t (i.e., if I leave these lines in my code), my component never “gets” to “capture the pointer” (I am sure since I observed that the “onGotPointerCapture” event is never fired). The consequence is that I “loose” my element if I am dragging to fast and if the mouse arrow is no longer over my component (which is exactly what I wanted to avoid in the first place with my setPointerCapture).
Please find below the relevant extracts of my code.
This is where I (try to) do my setPointerCapture. This function handles the onPointerDown event "fired" by the visual component that I would like to drag.
const onPointerDown = useCallback(
(event: React.PointerEvent) => {
// Case 1: We are neither allowed to drag nor to select
if (!draggingEnabled && !selectingEnabled) return;
// ...
// Case 2: We are allowed to drag (and we will select before we actually start dragging)
if (draggingEnabled) {
const target: HTMLDivElement = event.target as HTMLDivElement;
target.setPointerCapture(event.pointerId);
// ...
onStartDragging(id, event.shiftKey);
return;
}
// Case 3: We are "only" allowed to select
// ...
},
[id, draggingEnabled, selectingEnabled, onSelect, onStartDragging]
);
This is the interface defining the props that are used by my visual component that I would like to drag. The relevant property is, obviously, the zIndex.
export interface VisualDiagramElementData {
id: string;
top: number;
left: number;
height: number;
width: number;
// ...
zIndex: number;
// ...
}
Here my elements are declared using a useRef hook.
const elements: VisualDiagramElementData[] = useRef<
VisualDiagramElementData[]
>(
initialDiagramElements.map((element: DiagramElement) => ({
id: element.id,
top: element.top,
left: element.left,
height: element.height,
width: element.width,
// ...
zIndex: element.zIndex,
// ...
}))
).current;
This is the critical function. It handles the elements that are represented by my visual components that I would like to drag. elements uses the useRef hook (see above).
const onStartDragging = useCallback(
(id: string, shiftKeyPressed: boolean) => {
// We want to drag or to select. Select element with current id if not yet selected
// ...
// Adjust order of elements to ensure that dragged elememts are (and remain after dragging) on top
// If I comment this out from here ...
const sortedElements: VisualDiagramElementData[] = elements.sort(
(elementA, elementB) => {
if (elementA.selected && !elementB.selected) return 1;
if (!elementA.selected && elementB.selected) return -1;
return 0;
}
);
for (let i = 0; i < sortedElements.length; i++) {
sortedElements[i].zIndex = i + 1;
}
// ... to here, there is no issue any more!
requestUpdate();
},
[elements, isSelected, requestUpdate]
);
requestUpdate() updates the data (represented by an array using a useState hook) that is going as props into the component I would like to drag. auxElements are the "elements" from my useRef hook, i.e., the elements in the code above.
// useRef hooks
const requestUpdate = useRef(() => {
requestAnimationFrame(() => {
setElements([...auxElements]);
// ...
});
}).current;
// useState hooks
const [elements, setElements] = useState<VisualDiagramElementData[]>([]);

How to access arcgis map via components?

The code below is taken directly from arcgis via react on how to display a map.
If i wanted to say, zoom in to a set of coordinates, but the code for that was set in another component, how can i get that component to talk to the map here in this component?
import Map from '#arcgis/core/Map';
import MapView from '#arcgis/core/views/MapView';
const map = new Map({
basemap: "topo-vector"
});
const view = new MapView({
container: "viewDiv",
map: map
});
I resolved this by using redux toolkit to set the map as a global state object.
The entire map view is set in a useEffect, once i initialize each of the views, i dispatch the map view to a reducer in rtk.
dispatch(updateMapViewState(view));
updateMapViewState: (state, action) => {
state.view = action.payload
},
Then, when i want to use the map in a separate component, i do:
const view = useSelector((state) => state.MapSlice.view);
In this way, all components can access the map outside of the useEffect in the map component, and can manipulate it without creating a new map view. This worked for me. I assume you could probably do this with context api, but we aren't using that as a global state manager.
This may not be the recommended or best way to achieve this, but I had success by passing the view object from the map component back to the parent component, then saving it to the parent component's state.
// in App.js
saveViewToState(view){
this.setState({view: view})
}
<MapComponent saveViewToState={this.saveViewToState}/>
// in MapComponent.js
let view = new View()
this.props.saveViewToState(view)
Then I was able to interact with the view object from the parent:
// in App.js
this.state.view.extent = {xmin: 1, ymin: 1, xmax: 2, ymax: 2}
This doesn't work perfectly (for some reason I can't call view.goTo, but view.extent works). I'd be keen to hear if there is a better way to achieve this.
Still looking for a clear answer to this problem.
Have not found an example separating the map/view creation and adding layers into differing components.

How can I access React state in my eventHandler?

This is my state:
const [markers, setMarkers] = useState([])
I initialise a Leaflet map in a useEffect hook. It has a click eventHandler.
useEffect(() => {
map.current = Leaflet.map('mapid').setView([46.378333, 13.836667], 12)
.
.
.
map.current.on('click', onMapClick)
}, []
Inside that onMapClick I create a marker on the map and add it to the state:
const onMapClick = useCallback((event) => {
console.log('onMapClick markers', markers)
const marker = Leaflet.marker(event.latlng, {
draggable: true,
icon: Leaflet.divIcon({
html: markers.length + 1,
className: 'marker-text',
}),
}).addTo(map.current).on('move', onMarkerMove)
setMarkers((existingMarkers) => [ ...existingMarkers, marker])
}, [markers, onMarkerMove])
But I would also like to access the markers state here. But I can't read markers here. It's always the initial state. I tried to call onMapClick via a onClick handler of a button. There I can read markers. Why can't I read markers if the original event starts at the map? How can I read the state variables inside onMapClick?
Here is an example: https://codesandbox.io/s/jolly-mendel-r58zp?file=/src/map4.js
When you click in the map and have a look at the console you see that the markers array in onMapClick stays empty while it gets filled in the useEffect that listens for markers.
React state is asynchronous and it won't immediately guarantee you to give you the new state, as for your question Why can't I read markers if the original event starts at the map its an asynchronous nature and the fact that state values are used by functions based on their current closures and state updates will reflect in the next re-render by which the existing closures are not affected but new ones are created, this problem you wont face on class components as you have this instance in it, which has global scope.
As a developing a component , we should make sure the components are controlled from where you are invoking it, instead of function closures dealing with state , it will re-render every time state changes . Your solution is viable you should pass a value whatever event or action you pass to a function, when its required.
Edit:- its Simple just pass params or deps to useEffect and wrap your callback inside, for your case it would be
useEffect(() => {
map.current = Leaflet.map('mapid').setView([46.378333, 13.836667], 12)
.
.
.
map.current.on('click',()=> onMapClick(markers)) //pass latest change
}, [markers] // when your state changes it will call this again
for more info check this one out https://dmitripavlutin.com/react-hooks-stale-closures/ , it will help you for longer term !!!
Long one but you'll understand why this is happening and the better fixes. Closures are especially an issue (also hard to understand), mostly when we set click handlers which are dependent on the state, if the handler function with the new scope is not re-attached to the click event, then closures remain un-updated and hence the stale state remains in the click handler function.
If you understand it perfectly in your component, useCallback is returning a new reference to the updated function i.e onMapClick having your updated markers ( the state) in its scope, but since you are setting the 'click' handler only in the beginning when the component is mounted, the click handler remains un-updated since you've put a check if(! map.current), which prevents any new handler to be attached on the map.
// in sandbox map.js line 40
useEffect(() => {
// this is the issue, only true when component is initialized
if (! map.current) {
map.current = Leaflet.map("mapid4").setView([46.378333, 13.836667], 12);
Leaflet.tileLayer({ ....}).addTo(map.current);
// we must update this since onMapClick was updated
// but you're preventing this from happening using the if statement
map.current.on("click", onMapClick);
}
}, [onMapClick]);
Now I tried moving map.current.on("click", onMapClick); out of the if block, but there's an issue, Leaflets instead of replacing the click handler with the new function, it adds another event handler ( basically stacking event handlers ), so we must remove the old one before adding the new one, otherwise we will end up adding multiple handlers each time onMapClick is updated. For which we have the off() function.
Here's the updated code
// in sandbox map.js line 40
useEffect(() => {
// this is the issue, only true when component is initialized
if (!map.current) {
map.current = Leaflet.map("mapid4").setView([46.378333, 13.836667], 12);
Leaflet.tileLayer({ ....
}).addTo(map.current);
}
// remove out of the condition block
// remove any stale click handlers and add the updated onMapClick handler
map.current.off('click').on("click", onMapClick);
}, [onMapClick]);
This is the link to the updated sandbox which is working just fine.
Now there's another Idea to solve it without replacing click handler each time. i.e some globals, which I believe is not really too bad.
For this add globalMarkers outside but above your component and update it each time.
let updatedMarkers = [];
const Map4 = () => {
let map = useRef(null);
let path = useRef({});
updatedMarkers = markers; // update this variable each and every time with the new markers value
......
const onMapClick = useCallback((event) => {
console.log('onMapClick markers', markers)
const marker = Leaflet.marker(event.latlng, {
draggable: true,
icon: Leaflet.divIcon({
// use updatedMarkers here
html: updatedMarkers.length + 1,
className: 'marker-text',
}),
}).addTo(map.current).on('move', onMarkerMove)
setMarkers((existingMarkers) => [ ...existingMarkers, marker])
}, [markers, onMarkerMove])
.....
} // component end
And this one works perfectly too, Link to the sandbox with this code. This one works faster.
And lastly, the above solution of passing it as a param is okay too! I prefer the one with updated if block since it's easy to modify and you get the logic behind it.

reactjs run code after all elements are loaded

I am in my first steps with react. I am running Reactjs v16.11.0
I have page which trigger the webcam (following this mdn tutorial). So I want to call startup function when all elements were painted. I tried with window.addEventListener('load', startup, false); but it doesn't call any function.
So I tried the useEffectHook:
useEffect (() => {
startup();
}, []);
But it call the startup function too soon, and there is some elments that there aren't still in the DOM because it runs asyncronous code - video .
My startup function is this
const startup= () => {
video = document.getElementById('video');
let canvas: HTMLCanvasElement = document.getElementById('canvas') as HTMLCanvasElement;
const photo = document.getElementById('photo') as HTMLElement;
navigator.mediaDevices.getUserMedia({video: true, audio: false})
.then(function(stream) {
mediaStream = stream.getTracks()[0];
video.srcObject = stream;
video.play();
})
.catch(function(err) {
console.log("An error occurred: " + err);
});
if(video) {
video.addEventListener('canplay', function (ev: any) {
if (!streaming) {
height = video.videoHeight / (video.videoWidth / width);
// Firefox currently has a bug where the height can't be read from
// the video, so we will make assumptions if this happens.
if (isNaN(height)) {
height = width / (4 / 3);
}
video.setAttribute('width', width);
video.setAttribute('height', height);
canvas.setAttribute('width', width.toString());
canvas.setAttribute('height', height.toString());
streaming = true;
}
}, false);
//clearphoto(canvas, photo);
}
}
I am using functional component (instead of class component). And from what I understood componentDidMount works with class component. Am I correct?
How can accomplish to run the startup function only when every elements are in the DOM ?
EDIT: code edit in useEffect hook, noticed by Jayraj
I have just finished following the tutorial. It was interesting to me, as well.
First of all, you can play my demo: https://codesandbox.io/s/hungry-bassi-ojccj?file=/src/App.js.
To achieve the goal I used three hooks: useRef, useEffect, useState. Let's me explain why I have used each of them.
So, I would like to start with the useState hook. Before streaming, we should calculate the height of the image and canvas and set it. However, we must save the height into somewhere to get its value in our component. That's why I used the useState hook.
To draw canvas successfully I used the useRef hook. It allows me to access the DOM and that's why I removed calls the getElementById from the code as the hook is responsible for. I made the same with the video. I created the videoRef to access the DOM.
And the main that I called the useEffect hook two times. As you can see, the first useEffect hasn't any dependencies that's why it will be called once. It works like the componentDidMount method in this case. Thankfully to it, the getUserMedia method is called and we can set stream to the videoRef and afterwards the video will be started playing.
The second useEffect waitings for the changes of the videoRef property and then it starts executing.
I guess you should read about react hook more deeply to understand very well. Let's me attach the link of the documentation. https://reactjs.org/docs/hooks-reference.html
Have a good day.

Resources