React hooks. Update component passed as param via onClick after one of it's prop was changed - reactjs

Hi guys) I have a strange question may be, but I'm at a dead end.
I have my own custom hook.
const useModal = (Content?: ReactNode, options?: ModalOptions) => {
const { isOpen, close: contextClose, open: contextOpen, setContent } = useContext(
ModalContext,
)
const [customOpenContent, setCustomOpenContent] = useState<ReactNode>()
const showModal = useCallback(
(customContent?: ReactNode) => {
if (!isNil(customContent)) {
setCustomOpenContent(customContent)
contextOpen(customContent, options)
} else contextOpen(Content, options)
},
[contextOpen, Content, options],
)
const hideModal = useCallback(() => {
contextClose()
}, [contextClose])
return { isOpen, close: hideModal, open: showModal, setContent }
}
It is quite simple.
Also i have component which uses this hook
const App: React.FC = () => {
const [loading, setLoading] = useState(false)
const { open } = useModal(null, { deps: [loading] })
useEffect(() => {
setTimeout(() => {
setLoading(true)
}, 10000)
})
const buttonCallback = useCallback(() => {
open(<Button disabled={!loading}>Loading: {loading.toString()}</Button>)
}, [loading, open])
return (
<Page title="App">
<Button onClick={buttonCallback}>Open Modal</Button>
</Page>
)
}
Main problem is - Button didn't became enabled because useModal hook doesn't know anything about changes.
May be you have an idea how to update this component while it's props are updated? And how to do it handsomely ))

Context isn't the best solution to this problem. What you want is a Portal instead. Portals are React's solution to rendering outside of the current React component hierarchy. How to use React Portal? is a basic example, but as you can see, just going with the base React.Portal just gives you the location to render.
Here's a library that does a lot of the heavy lifting for you: https://github.com/wellyshen/react-cool-portal. It has typescript definitions and provides an easy API to work with.
Here's your example using react-cool-portal.
import usePortal from "react-cool-portal";
const App = () => {
const [loading, setLoading] = useState(false);
const { Portal, isShow, toggle } = usePortal({ defaultShow: false });
useEffect(() => {
setTimeout(() => {
setLoading(true);
}, 10000);
});
const buttonCallback = useCallback(() => {
toggle();
}, [toggle]);
return (
<div title="App" style={{ backgroundColor: "hotpink" }}>
<button onClick={buttonCallback}>
{isShow ? "Close" : "Open"} Modal
</button>
<Portal>
<button disabled={!loading}>Loading: {loading.toString()}</button>
</Portal>
<div>{loading.toString()}</div>
</div>
);
};
Basic CodeSandbox Example
There are more detailed ones within the react-cool-portal documentation.
For more detail of the issues with the Context solution you were trying, is that React Elements are just a javascript object. React then uses the object, it's location in the tree, and it's key to determine if they are the same element. React doesn't actually care or notice where you create the object, only it's location in the tree when it is rendered.
The disconnect in your solution is that when you pass the element to the open function in buttonCallback, the element is created at that point. It's a javascript object that then is set as the content in your context. At that point, the object is set and won't change until you called open again. If you set up your component to call open every time the relevant state changes, you could get it working that way. But as I mentioned earlier, context wasn't built for rendering components outside of the current component; hence why some really weird workarounds would be required to get it working.

Related

Simulate user navigation with React Testing Library and React Router

I have a component that is meant to trigger when the user navigates away from a page. It wraps a formik form; if a user has unsaved changes, it attempts to save those changes as soon as the user attempts to navigate away. While the save is resolving, users will see a modal that says "saving..." If the save is successful, the user continues on to the next page. If it is unsuccessful, it displays a modal prompting them to either stay or move along. The component works fine, but I'm struggling to test it.
Component in question:
const AutoSaveUnsavedChangesGuard: React.FC<Props> = ({
when,
onLeave,
children,
ignoreRoutes = [],
submitForm,
}) => {
const { push } = useHistory();
const { error, savingStatus } = useSavingStatusContext();
const [nextLocation, setNextLocation] = React.useState<string>();
const [isShowing, setIsShowing] = React.useState<boolean>(false);
const [showUnsavedChangesModal, setShowUnsavedChangesModal] = React.useState<boolean>(false);
const [showSavingModal, setShowSavingModal] = React.useState<boolean>(false);
const handleBlockNavigation = (nextLocation: Location) => {
if (!!matchPath(nextLocation.pathname, ignoreRoutes)) {
return true;
}
setNextLocation(nextLocation.pathname);
setIsShowing(true);
submitForm();
return false;
};
React.useEffect(() => {
// Proceed to next location when there has been a navigation attempt and client no longer blocks it
if (!when && nextLocation) {
push(nextLocation);
}
}, [when, nextLocation, push]);
React.useEffect(() => {
// If we have an error and we have triggered the Prompt, display the unsaved changes guard.
setShowUnsavedChangesModal(!!error)
}, [error]);
React.useEffect(() => {
setShowSavingModal(savingStatus=== SavingStatusType.SAVING)
}, [savingStatus]);
return (
<React.Fragment>
<Prompt when={when} message={handleBlockNavigation}/>
<UnsavedChangesModal
show={showUnsavedChangesModal && isShowing}
onLeave={() => {
onLeave && onLeave();
}}
onStay={() => {
setNextLocation(undefined);
}}
onHide={() => {
setIsShowing(false);
setShowUnsavedChangesModal(false);
}}
/>
<SavingModal show={showSavingModal && isShowing} />
{children}
</React.Fragment>
);
};
export default AutoSaveUnsavedChangesGuard;
I'm trying to test behavior with react-testing-library. I'd like to simulate a user navigating away (IE call the message method in the rendered component with a new location), but I am struggling to do so. We had a function like the one below when we tested using enzyme.
const changeRouteLocation = (nextLocation: Location, wrapper: ShallowWrapper) => {
const prompt = wrapper.find(ReactRouter.Prompt);
const onNavigate = prompt.props().message as (location: Location) => string | boolean;
onNavigate(nextLocation);
};
Unfortunately, this component uses useEffect hooks that don't play nice with enzyme, and I must test it using react-testing-library. How can I simulate a user attempting to navigate to a new location with react-testing-library?
Edit: Adding what I have for testing code per a request. This code does not produce the desired outcome, and I honestly didn't expect it to.
const RenderingComponent = ({initialEntries})=>{
return(
<ThemeProvider>
<MemoryRouter initialEntries={initialEntries}>
<AutoSaveUnsavedChangesGuard {...defaults} />
</MemoryRouter>
</ThemeProvider>
)
}
beforeEach(() => {
jest.spyOn(ReactRouter, 'useHistory').mockReturnValue(makeHistory());
useSavingStatusContextSpy = jest.spyOn(useAutoSaveContextModule, 'useSavingStatusContext')
});
it('should render default. It should not show any modals when there are no errors and the route has not changed.', async () => {
// Default rendering. Works fine, because it's not meant to display anything.
const wrapper = render(
<RenderingComponent initialEntries={['/initial-value']} />
)
expect(screen.queryByText('Saving...')).toBeNull();
expect(screen.queryByText('Unsaved Changes')).toBeNull();
expect(wrapper).toMatchSnapshot()
});
it('should show the saving modal when the route location changes and saving status context is of type SAVING',()=>{
useSavingStatusContextSpy.mockReturnValueOnce(makeAutoSaveContext({savingStatus: SavingStatusType.SAVING}))
const {rerender, debug} = render(
<RenderingComponent initialEntries={["initial-value"]} />
)
rerender(<RenderingComponent initialEntries={['/mock-value','/mock-some-new-value']} />)
// I had hoped that re-rendering with new values for initial entries would trigger a navigation event for the prompt to block. It did not work.
debug()
const savingModal = screen.getByText('Saving...');
expect(savingModal).toBeVisible();
})
})

React (+ Typescript) component not rerendering upon updating Context

I have a LaunchItem component which uses React.Context to get and set information to/from the local storage.
What I am trying to achieve is that, when the component updates the Context (and local storage), I want it to rerender with the new information, so that it then updates the state of a local button.
The problem is, although the Context seems to be updated as well as the contents of the local storage, the item is not rerendered. (when I refresh the page I can see the button has changed state, however, signifying that it is able to derive that information from the Context just fine.
I will now share some code and hopefully someone is able to understand what I might be missing, I thoroughly appreciate your help :)
Context provider setup
type FavoritesContextType = {
favorites: Favorites;
updateFavorites: (category: StorageCategory, item: string) => void;
};
export const FavoritesContext = createContext<FavoritesContextType>(
{} as FavoritesContextType
);
const FavoritesProvider: FC = ({ children }) => {
const [favorites, setFavorites] = useState<Favorites>(
getFromLocalStorage(SOME_CONSTANT)
);
const updateFavorites = (category: StorageCategory, item: string) => {
updateLocalStorage(category, item);
setFavorites(favorites);
};
return (
<FavoritesContext.Provider value={{ favorites, updateFavorites }}>
{children}
</FavoritesContext.Provider>
);
};
export const useFavoritesContext = () => useContext(FavoritesContext);
App.tsx
export const App = () => {
return (
<FavoritesProvider>
{/* Some routing wrapper and a few routes each rendering a component */}
<Route path="/launches" element={<Launches />} />
</FavoritesProvider>
)
Launches.tsx
export const LaunchItem = ({ launch }: LaunchItemProps) => {
const { favorites, updateFavorites } = useFavoritesContext();
const [isFavorite, setIsFavorite] = useState(false);
useEffect(() => {
if (favorites) {
setIsFavorite(
favorites.launches.includes(launch.flight_number.toString())
);
}
}, [favorites]);
return (
{/* The rest of the component, irrelevant */}
<FavoriteButton
isFavorite={isFavorite}
updateFavorites={() => {
updateFavorites(
StorageCategory.Launches,
launch.flight_number.toString()
);
}}
/>
)
FavoriteButton.tsx
export const FavoriteButton = ({
isFavorite,
updateFavorites,
}: FavoriteButtonProps) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
e.preventDefault();
updateFavorites();
};
return (
// Using Link vs a Button to be able to preventDefault of parent Link
<Link
onClick={handleClick}
>
{/* The rest of the component, irrelevant */}
It seems as though in your updateFavorites function you're calling setFavorites and passing in the existing favorites value. Try instead writing your updateFavorites function as:
const updateFavorites = (category: StorageCategory, item: string) => {
updateLocalStorage(category, item);
setFavorites(getFromLocalStorage(SOME_CONSTANT));
};
There are other ways you could determine what value to pass to setFavorites but I reused your getFromLocalStorage function as I'm not sure how you're determining that state value.
By doing it this way you'll ensure that the value you're setting in setFavorites isn't the same as the existing favorites value and thus you'll trigger a re-render.

Set React Context inside function-only component

My goal is very simple. I am just looking to set my react context from within a reusable function-only (stateless?) react component.
When this reusable function gets called it will set the context (state inside) to values i provide. The problem is of course you can't import react inside a function-only component and hence I cannot set the context throughout my app.
There's nothing really to show its a simple problem.
But just in case:
<button onCLick={() => PlaySong()}></button>
export function PlaySong() {
const {currentSong, setCurrentSong} = useContext(StoreContext) //cannot call useContext in this component
}
If i use a regular react component, i cannot call this function onClick:
export default function PlaySong() {
const {currentSong, setCurrentSong} = useContext(StoreContext) //fine
}
But:
<button onCLick={() => <PlaySong />}></button> //not an executable function
One solution: I know i can easily solve this problem by simply creating a Playbtn component and place that in every song so it plays the song. The problem with this approach is that i am using a react-player library so i cannot place a Playbtn component in there...
You're so close! You just need to define the callback inside the function component.
export const PlaySongButton = ({...props}) => {
const {setCurrentSong} = useContext(StoreContext);
const playSong = () => {
setCurrentSong("some song");
}
return (
<button
{...props}
onClick={() => playSong()}
/>
)
}
If you want greater re-usability, you can create custom hooks to consume your context. Of course where you use these still has to follow the rules of hooks.
export const useSetCurrentSong = (song) => {
const {setCurrentSong} = useContext(StoreContext);
setCurrentSong(song);
}
It is possible to trigger a hook function by rendering a component, but you cannot call a component like you are trying to do.
const PlaySong = () => {
const {setCurrentSong} = useContext(StoreContext);
useEffect( () => {
setCurrentSong("some song");
}, []
}
return null;
}
const MyComponent = () => {
const [shouldPlay, setShouldPlay] = useState(false);
return (
<>
<button onClick={() => setShouldPlay(true)}>Play</button>
{shouldPlay && <PlaySong />}
</>
)
}

Testing mouse event listener added using ref in React functional component

Hi I have a functional component as shown below:
import React, { useRef, useEffect, useState } from 'react';
const SomeComponent = ({ prop1, ...otherProps}) => {
const divRef = useRef();
useEffect(() => {
divRef.current.addEventListener('mousedown', mouseDownFunc);
}, []);
const mouseDownFunc = () => {
document.addEventListener('mousemove', (el) => {
// call some parent function
});
}
return (
<div
className='test-div'
ref={ divRef }>
</div>
);
};
How do I test a react functional component wherein addEventListener is added using ref inside useEffect which when triggered calls mouseDownFunc.
I'm new to react jest testing, little confused on how to do it.
Testing this sort of component can be tricky, but using #testing-library/react I think I was able to come up with something useful.
I did have to make some changes to your component to expose the API a bit, and I also made some changes so that it stops listening to the events on mouseup which may not be the specific event you want.
Here's the modified component:
// MouseDownExample.js
import React, { useEffect, useState } from "react";
export default ({ onMouseMoveWhileDown }) => {
const [x, setX] = useState(null);
const [listening, setListening] = useState();
// Replaced with mouse move function, should make sure we're unlistening as well
useEffect(() => {
if (listening) {
const onMouseMove = (event) => {
// call some parent function
onMouseMoveWhileDown(event);
console.log(event.clientX);
// purely for testing purposes
setX(event.clientX);
};
const onMouseUp = (event) => {
// stop listening on mouse up
// - you should pick whatever event you want to stop listening
// - this is global so it also stops when the mouse is outside the box
setListening(false);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
}
}, [listening, onMouseMoveWhileDown]);
return (
<div
style={{
backgroundColor: "red",
width: 200,
height: 200
}}
className="test-div"
onMouseDown={() => {
// moved this inline, so no ref
setListening(true);
}}
>
X Position: {x}
</div>
);
};
I called out in comments the main differences.
And here's an example test:
// MouseDownExample.test.js
import React from "react";
import { fireEvent, render } from "#testing-library/react";
import MouseDownExample from "./MouseDownExample";
it("shouldn't trigger onMouseMoveWhileDown when mouse isn't down", () => {
const onMouseMoveWhileDown = jest.fn();
const { container } = render(
<MouseDownExample onMouseMoveWhileDown={onMouseMoveWhileDown} />
);
// Note: normally I would use `screen.getByRole` but divs don't have a useful role
const subject = container.firstChild;
fireEvent.mouseMove(
document,
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/MouseEvent
{
clientX: 200
}
);
// hasn't gone down yet
expect(onMouseMoveWhileDown).not.toHaveBeenCalled();
fireEvent.mouseDown(subject);
fireEvent.mouseUp(subject);
// went down then up before moving
fireEvent.mouseMove(document, {
clientX: 200
});
expect(onMouseMoveWhileDown).not.toHaveBeenCalled();
});
it("should trigger onMouseMoveWhileDown when mouse is down", () => {
const onMouseMoveWhileDown = jest.fn();
const { container } = render(
<MouseDownExample onMouseMoveWhileDown={onMouseMoveWhileDown} />
);
// Note: normally I would use `screen.getByRole` but divs don't have a useful role
const subject = container.firstChild;
fireEvent.mouseDown(subject);
fireEvent.mouseMove(document, {
clientX: 200
});
expect(onMouseMoveWhileDown).toHaveBeenCalledWith(
expect.objectContaining({ clientX: 200 })
);
});
What's happening here, is we're rendering the component, then firing events to ensure the onMouseMoveWhileDown function prop is called when we expect.
We have to do expect.objectContaining rather than just the object because it's called with a MouseEvent which contains other properties.
Another test we might want to add is an unmount test to ensure the listeners are no longer triggering events.
You can look at/experiment with this Code Sandbox with this component and the tests. Hope this helps 👍

React ref anti-pattern for Popups

Recently I've found myself implementing a ref based pattern that seems to go against react documentation advice.
The pattern goes like this :
type Callback = () => void;
type CallbackWrapper = {callback : Callback}
interface IWarningPopupRef{
warn : (callback : Callback) => void;
}
interface IWarningPopupProps{
warningText : string;
}
const WarningPopup = forwardRef<IWarningPopupRef, IWarningPopupProps>(
const [show, setShow] = useState(false);
const [callback, setCallback] = useState<CallbackWrapper | null>(null);
const warn = (callback : Callback) => {
setShow(true);
setCallback({callback});
}
const acceptWarning = () => {
setShow(false);
setCallback(null);
if(callback != null) callback.callback();
}
useImperativeHandle(ref, () => ({
warn
}));
(props, ref) => {
return (
<div style={{
visibility:(show)?"visible":"hidden"
}}>
{props.warningText}
<button onClick={acceptWarning}>Accept</button>
</div>
)
}
)
const Component : React.FC = props => {
const warningPopupRef = useRef<IWarningPopupRef>(null);
const doDangerButton = () => {
warningPopupRef.current!.warn(() => {
doDangerAction();
});
}
return (
<button onClick={doDangerButton}>Dangerous button</button>
<WarningPopup ref={warningPopupRef}
warningText="Warning ! This is a dangerous button !"/>
)
}
If I were to follow react documentation advice and lift the state to the parent component I'd have this :
interface IWarningPopupProps{
warningText : string;
show : boolean;
onWarningAccept : () => void;
}
const WarningPopup : React.FC<IWarningPopupProps> = props => {
return (
<div style={{
visibility:(props.show)?"visible":"hidden"
}}>
{props.warningText}
<button onClick={props.onWarningAccept}>Accept</button>
</div>
)
}
const Component : React.FC = props => {
const [warningPopupShow, setWarningPopupShow] = useState(false);
const doDangerButton = () => {
setWarningPopupShow(true);
}
const acceptWarning = () => {
setWarningPopupShow(false);
doDangerAction();
}
return (
<button onClick={doDangerButton}>Dangerous button</button>
<WarningPopup warningText="Warning ! This is a dangerous button !"
show={warningPopupShow}
onWarningAccept={acceptWarning}/>
)
}
Now I don't do the above because I'm concerned about abstraction leakage and my parent component having to handle both the state it was created to manipulate and this popup state.
My reasoning is that the popup is an interruption of navigation flow and as such should be handled in it's own context.
Am I laying a trap for my future self with this (anti-)pattern ?
I approve the second, more "React-y", solution because:
It makes sense that your parent holds the shown state. In later uses, you might be really happy to have more flexibility over popup control flow.
Your original pattern is much more complex to read and to maintain as you expose an imperative API which is less obvious and usually require additional documentation. Instead, props are simple standard React, are much more predictable and testable.
Refs impose the parent/user to bypass React's life cycle by forcing them to check that the ref contains an element at the expected time.
Finally, you can adjust your popup in a way that is convenient to use for you:
const Component : React.FC = props => {
const [warningPopupShow, setWarningPopupShow] = useState(false);
return (
<button onClick={doDangerButton}>Dangerous button</button>
<WarningPopup warningText="Warning ! This is a dangerous button !"
show={warningPopupShow}
onShow={setWarningPopupShow} // Simply separate "shown" updates from
// acceptation action in the popup
onWarningAccept={doDangerAction}/>
)
}
Simple, functional, idiomatic.

Resources