React forwardRef and callback ref, ref becomes undefined - reactjs

This is not a Mapbox related issue, and you don’t need any map or related stuff knowledge to help. My issue is related to React refs.
I’m using react-map-gl (Mapbox for React) with #mapbox/mapbox-gl-draw that allow user to draw geometries on the map. This app has 2 components: DrawControl that exposes draw features and AllPastures that renders the map and manage create/update/delete geometries drawed.
When user draws a polygon it fires the DrawControl method onCreate that executes onCreateOrUpdate. I’m passing the Draw by forwardRef from DrawControl to AllPastures.
This is a DrawControl component:
export const DrawControl = React.forwardRef<MapboxDraw, DrawControlProps>(
(props: DrawControlProps, ref) => {
const drawRef: MapboxDraw = useControl<MapboxDraw>(
() => new MapboxDraw(props),
({ map }) => {
map.on('draw.create', props.onCreate);
map.on('draw.update', props.onUpdate);
map.on('draw.delete', props.onDelete);
map.on('draw.modechange', props.onModeChange);
},
({ map }) => {
map.off('draw.create', props.onCreate);
map.off('draw.update', props.onUpdate);
map.off('draw.delete', props.onDelete);
map.off('draw.modechange', props.onModeChange);
},
{
position: props.position,
}
);
// exposing drawRef outside the component by Ref
React.useImperativeHandle(ref, () => drawRef, [drawRef]);
return null;
}
and this is part of AllPastures component:
export const AllPastures = () => {
...
const [drawRef, setDrawRef] = React.useState<MapboxDraw>();
// here I’m using callback ref to react to drawRef changes
const onDrawRef = React.useCallback((ref: MapboxDraw | null) => {
if (ref) {
setDrawRef(ref);
}
}, []);
React.useEffect(() => {
// it's ok here, drawRef is not undefined
console.log('useEffect drawRef when app is loading', drawRef);
}, [map, drawRef]);
const onCreateOrUpdate = (e: { features: Feature[] }) => {
// Why drawRef is undefined here????????????????????
console.log('drawRef under onCreateOrUpdate method', drawRef);
};
...
return (
<DrawControl
ref={onDrawRef}
position="top-right"
displayControlsDefault={false}
controls={{
polygon: true,
trash: true,
}}
defaultMode="simple_select"
onCreate={onCreateOrUpdate}
onUpdate={onCreateOrUpdate}
/>
);
};
My issue is, discover why when the the method onCreateOrUpdate the drawRef is undefined?
Here is a related sandbox simulating the issue:
https://stackblitz.com/edit/vitejs-vite-bvutvb?file=src%2FAllPastures.tsx
Just draw any polygon on the map and check the console.log drawRef under onCreateOrUpdate method is undefined.
Please, after make changes in the code, do a F5 to complete refresh the page and test it again.

const onCreateOrUpdate = React.useCallback((e: { features: Feature[] }) => {
// Here drawRef would not be undefined
console.log('drawRef under onCreateOrUpdate method', drawRef);
}, [drawRef]);

Related

How to test for document being undefined with RTL?

I have the following react hook which brings focus to a given ref and on unmount returns the focus to the previously focused element.
export default function useFocusOnElement(elementRef: React.RefObject<HTMLHeadingElement>) {
const documentExists = typeof document !== 'undefined';
const [previouslyFocusedEl] = useState(documentExists && (document.activeElement as HTMLElement));
useEffect(() => {
if (documentExists) {
elementRef.current?.focus();
}
return () => {
if (previouslyFocusedEl) {
previouslyFocusedEl?.focus();
}
};
}, []);
}
Here is the test I wrote for it.
/**
* #jest-environment jsdom
*/
describe('useFocusOnElement', () => {
let ref: React.RefObject<HTMLDivElement>;
let focusMock: jest.SpyInstance;
beforeEach(() => {
ref = { current: document.createElement('div') } as React.RefObject<HTMLDivElement>;
focusMock = jest.spyOn(ref.current as HTMLDivElement, 'focus');
});
it('will call focus on passed ref after mount ', () => {
expect(focusMock).not.toHaveBeenCalled();
renderHook(() => useFocusOnElement(ref));
expect(focusMock).toHaveBeenCalled();
});
});
I would like to also test for the case where document is undefined as we also do SSR. In the hook I am checking for the existence of document and I would like to test for both cases.
JSDOM included document so I feel I'd need to remove it and some how catch an error in my test?
First of all, to simulate document as undefined, you should mock it like:
jest
.spyOn(global as any, 'document', 'get')
.mockImplementationOnce(() => undefined);
But to this work in your test, you will need to set spyOn inside renderHook because looks like it also makes use of document internally, and if you set spyOn before it, you will get an error.
Working test example:
it('will NOT call focus on passed ref after mount', () => {
expect(focusMock).not.toHaveBeenCalled();
renderHook(() => {
jest
.spyOn(global as any, 'document', 'get')
.mockImplementationOnce(() => undefined);
useFocusOnElement(ref);
});
expect(focusMock).not.toHaveBeenCalled();
});
You should be able to do this by creating a second test file with a node environment:
/**
* #jest-environment node
*/
describe('useFocusOnElement server-side', () => {
...
});
I ended up using wrapWithGlobal and wrapWithOverride from https://github.com/airbnb/jest-wrap.
describe('useFocusOnElement', () => {
let ref: React.RefObject<HTMLDivElement>;
let focusMock: jest.SpyInstance;
let activeElMock: unknown;
let activeEl: HTMLDivElement;
beforeEach(() => {
const { window } = new JSDOM();
global.document = window.document;
activeEl = document.createElement('div');
ref = { current: document.createElement('div') };
focusMock = jest.spyOn(ref.current as HTMLDivElement, 'focus');
activeElMock = jest.spyOn(activeEl, 'focus');
});
wrapWithOverride(
() => document,
'activeElement',
() => activeEl,
);
describe('when document present', () => {
it('will focus on passed ref after mount and will focus on previously active element on unmount', () => {
const hook = renderHook(() => useFocusOnElement(ref));
expect(focusMock).toHaveBeenCalled();
hook.unmount();
expect(activeElMock).toHaveBeenCalled();
});
});
describe('when no document present', () => {
wrapWithGlobal('document', () => undefined);
it('will not call focus on passed ref after mount nor on previously active element on unmount', () => {
const hook = renderHook(() => useFocusOnElement(ref));
expect(focusMock).not.toHaveBeenCalled();
hook.unmount();
expect(activeElMock).not.toHaveBeenCalled();
});
});
});

How to correctly get mouse events in react without re-rendering the parent component

I am trying to make a hook that returns the clientX and clientY values when the mouse moves on the screen. My hook looks like this -
useMouseMove hook
const useMouseMove = () => {
const [mouseData, setMouseData] = useState<[number, number]>([0, 0])
useEffect(() => {
const handleMouse = (e: MouseEvent) => {
setMouseData([e.clientX, e.clientY])
}
document.addEventListener("mousemove", handleMouse)
return () => {
document.removeEventListener("mousemove", handleMouse)
}
}, [])
return mouseData
}
And I'm using it in another component like so,
Usage in component
const SomeComponent = () => {
const mouseData = useMouseMoveLocation()
console.log("Rendered") // I want this to be rendered only once
useEffect(() => {
// I need to use the mouseData values here
console.log({ mouseData })
}, [mouseData])
return <>{/* Some child components */}</>
}
I need to use the mouseData values from the useMouseMove hook in the parent component (named SomeComponent in the above example) without re-rendering the entire component every time the mouse moves across the screen. Is there a correct way to do this to optimise for performance?
If you're not going to be rendering this component, then you can't use a useEffect. useEffect's only get run if your component renders. I think you'll need to run whatever code you have in mind in the mousemove callback:
const useMouseMove = (onMouseMove) => {
useEffect(() => {
document.addEventListener("mousemove", onMouseMove)
return () => {
document.removeEventListener("mousemove", onMouseMove)
}
}, [onMouseMove])
}
const SomeComponent = () => {
useMouseMove(e => {
// do something with e.clientX and e.clientY
});
}

How to pass ref to draw control in react-map-gl

Following the Mapbox draw example I can use the draw variable to access all features that are drawn on a map.
const draw = new MapboxDraw({
// ...
});
map.addControl(draw);
// ...
function updateArea(e) {
const data = draw.getAll(); // Accessing all features (data) drawn here
// ...
}
However, in react-map-gl library useControl example I can not figure out how to pass ref to the DrawControl component so I can use it as something like draw.current in a similar way as I did draw in normal javascript above.
In my DrawControl.jsx
const DrawControl = (props) => {
useControl(
({ map }) => {
map.on('draw.create', props.onCreate);
// ...
return new MapboxDraw(props);
},
({ map }) => {
map.off('draw.create', props.onCreate);
// ...
},{
position: props.position,
},
);
return null;
};
In my MapDrawer.jsx
import Map from 'react-map-gl';
import DrawControl from './DrawControl';
// ...
export const MapDrawer = () => {
const draw = React.useRef(null);
const onUpdate = React.useCallback((e) => {
const data = draw.current.getAll(); // this does not work as expected
// ...
}, []);
return (
// ...
<Map ...>
<DrawControl
ref={draw}
onCreate={onUpdate}
onUpdate={onUpdate}
...
/>
</Map>
)
}
I also get an error stating I should use forwardRef but I'm not really sure how.
react_devtools_backend.js:3973 Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
What I need is basically to delete the previous feature if there is a new polygon drawn on a map so that only one polygon is allowed on a map. I want to be able to do something like this in the onUpdate callback.
const onUpdate = React.useCallback((e) => {
// ...
draw.current.delete(draw.current.getAll.features[0].id);
// ...
}, []);
I had the similar problem recently with that lib, I solved it doing the following :
export let drawRef = null;
export default const DrawControl = (props) => {
drawRef = useControl(
({ map }) => {
map.on('draw.create', props.onCreate);
// ...
return new MapboxDraw(props);
},
({ map }) => {
map.off('draw.create', props.onCreate);
// ...
},{
position: props.position,
},
);
return null;
};
import DrawControl, {drawRef} from './DrawControl';
// ...
export const MapDrawer = () => {
const draw = drawRef;
const onUpdate = React.useCallback((e) => {
const data = draw?draw.current.getAll():null; // this does not work as expected
// ...
}, []);
return (
// ...
<Map ...>
<DrawControl
onCreate={onUpdate}
onUpdate={onUpdate}
...
/>
</Map>
)
}
const onUpdate = React.useCallback((e) => {
// ...
drawRef.delete(drawRef.getAll.features[0].id);
// ...
}, []);
Once component created, the ref is available for use.
Not that elegant but working... Sure there might be cleaner way...
Hope that helps!
Cheers
Pass draw from draw control to parent component.
const DrawControl = (props) => {
const [draw, setDraw] = useState()
const { setDraw: setDrawInParent, onUpdate, onCreate, onDelete } = props;
useEffect(() => {
if (draw) setDrawInParent(draw)
}, [draw])
useControl(
({ map }) => {
map.on("draw.create", onCreate);
map.on("draw.update", onUpdate);
map.on("draw.delete", onDelete);
const draw = new MapboxDraw(props);
setDraw(draw);
return draw;
}
);
return null;
};
I think I found a better solution combine forwardRef and useImperativeHandle to solve:
export const DrawControl = React.forwardRef((props: DrawControlProps, ref) => {
const drawRef = useControl<MapboxDraw>(
() => new MapboxDraw(props),
({ map }) => {
map.on("draw.create", props.onCreate);
map.on("draw.update", props.onUpdate);
map.on("draw.delete", props.onDelete);
map.on("draw.modechange", props.onModeChange);
},
({ map }) => {
map.off("draw.create", props.onCreate);
map.off("draw.update", props.onUpdate);
map.off("draw.delete", props.onDelete);
map.off("draw.modechange", props.onModeChange);
},
{
position: props.position,
}
);
React.useImperativeHandle(ref, () => drawRef, [drawRef]); // This way I exposed drawRef outside the component
return null;
});
in the component:
const drawRef = React.useRef<MapboxDraw>();
const [drawMode, setDrawMode] = React.useState<DrawMode>(“draw_polygon");
const changeModeTo = (mode: DrawMode) => {
// If you programmatically invoke a function in the Draw API, any event that directly corresponds with that function will not be fired
drawRef.current?.changeMode(mode as string);
setDrawMode(mode);
};
<>
<DrawControl
ref={drawRef}
position="top-right”
displayControlsDefault={false}
controls={{
polygon: true,
trash: true,
}}
defaultMode=“draw_polygon"
onCreate={onUpdate}
onUpdate={onUpdate}
onDelete={onDelete}
onModeChange={onModeChange}
/>
<button
style={{
position: ‘absolute’,
left: ‘20px’,
top: ‘20px’,
backgroundColor: '#ff0000’,
}}
onClick={() => changeModeTo('simple_select’)}
>
Change to Simple Select
</button>
<>

Mock react hook and keep existing module

I am using Chakra UI and I have to mock just one hook, because I want to simulate various viewports.
My component is using the hook as following:
export const AuthenticationBase = (props: props) => {
const [isMobile] = useMediaQuery(['(max-width: 768px)']);
return (
<Box textAlign="center" fontSize={isMobile ? 'sm' : 'xl'}>
</Box>
);
}
I tried as following:
// runs before any tests start running
jest.mock("#chakra-ui/react", () => {
// --> Original module
const originalModule = jest.requireActual("#chakra-ui/react");
return {
__esModule: true,
...originalModule,
useMediaQuery: jest.fn().mockImplementation(() => [true]),
};
});
And then I execute my test:
describe('Authentication Component', () => {
it('should load with Login', () => {
const container = mount(<ChakraProvider theme={theme}><AuthenticationBase screen="login" /></ChakraProvider>);
const title = container.find('h1');
expect(title).toBeTruthy();
expect(title.text()).toBe('Login');
container.unmount();
});
}
But I get an error from JEST, somehow the hook is not mock correctly:

React hooks : how to watch changes in a JS class object?

I'm quite new to React and I don't always understand when I have to use hooks and when I don't need them.
What I understand is that you can get/set a state by using
const [myState, setMyState] = React.useState(myStateValue);
So. My component runs some functions based on the url prop :
const playlist = new PlaylistObj();
React.useEffect(() => {
playlist.loadUrl(props.url).then(function(){
console.log("LOADED!");
})
}, [props.url]);
Inside my PlaylistObj class, I have an async function loadUrl(url) that
sets the apiLoading property of the playlist to true
gets content
sets the apiLoading property of the playlist to false
Now, I want to use that value in my React component, so I can set its classes (i'm using classnames) :
<div
className={classNames({
'api-loading': playlist.apiLoading
})}
>
But it doesn't work; the class is not updated, even if i DO get the "LOADED!" message in the console.
It seems that the playlist object is not "watched" by React. Maybe I should use react state here, but how ?
I tested
const [playlist, setPlaylist] = React.useState(new PlaylistObj());
React.useEffect(() => {
//refresh playlist if its URL is updated
playlist.loadUrl(props.playlistUrl).then(function(){
console.log("LOADED!");
})
}, [props.playlistUrl]);
And this, but it seems more and more unlogical to me, and, well, does not work.
const [playlist, setPlaylist] = React.useState(new PlaylistObj());
React.useEffect(() => {
playlist.loadUrl(props.playlistUrl).then(function(){
console.log("LOADED!");
setPlaylist(playlist); //added this
})
}, [props.playlistUrl]);
I just want my component be up-to-date with the playlist object. How should I handle this ?
I feel like I'm missing something.
Thanks a lot!
I think you are close, but basically this issue is you are not actually updating a state reference to trigger another rerender with the correct loading value.
const [playlist, setPlaylist] = React.useState(new PlaylistObj());
React.useEffect(() => {
playlist.loadUrl(props.playlistUrl).then(function(){
setPlaylist(playlist); // <-- this playlist reference doesn't change
})
}, [props.playlistUrl]);
I think you should introduce a second isLoading state to your component. When the effect is triggered whtn the URL updates, start by setting loading true, and when the Promise resolves update it back to false.
const [playlist] = React.useState(new PlaylistObj());
const [isloading, setIsLoading] = React.useState(false);
React.useEffect(() => {
setIsLoading(true);
playlist.loadUrl(props.playlistUrl).then(function(){
console.log("LOADED!");
setIsLoading(false);
});
}, [props.playlistUrl]);
Use the isLoading state in the render
<div
className={classNames({
'api-loading': isLoading,
})}
>
I also suggest using the finally block of a Promise chain to end the loading in the case that the Promise is rejected your UI doesn't get stuck in the loading "state".
React.useEffect(() => {
setIsLoading(true);
playlist.loadUrl(props.playlistUrl)
.then(function() {
console.log("LOADED!");
})
.finally(() => setIsLoading(false));
}, [props.playlistUrl]);
Here you go:
import React from "react";
class PlaylistAPI {
constructor(data = []) {
this.data = data;
this.listeners = [];
}
addListener(fn) {
this.listeners.push(fn);
}
removeEventListener(fn) {
this.listeners = this.listeners.filter(prevFn => prevFn !== fn)
}
setPlayList(data) {
this.data = data;
this.notif();
}
loadUrl(url) {
console.log("called loadUrl", url, this.data)
}
notif() {
this.listeners.forEach(fn => fn());
}
}
export default function App() {
const API = React.useMemo(() => new PlaylistAPI(), []);
React.useEffect(() => {
API.addListener(loadPlaylist);
/**
* Update your playlist and when user job has done, listerners will be called
*/
setTimeout(() => {
API.setPlayList([1,2,3])
}, 3000)
return () => {
API.removeEventListener(loadPlaylist);
}
}, [API])
function loadPlaylist() {
API.loadUrl("my url");
}
return (
<div className="App">
<h1>Watching an object by React Hooks</h1>
</div>
);
}
Demo in Codesandbox

Resources