How to detect route changes using React Router in React? - reactjs

I have a search component that is global to my application, and displays search results right at the top. Once the user does any sort of navigation, e.g., clicking a search result or using the back button on the browser, I want to reset the search: clear the results and the search input field.
Currently, I am handling this with a Context; I have a SearchContext.Provider that broadcasts the resetSearch function, and wherever I am handling navigation, I have consumers that invoke resetSearch before processing navigation (which I do programmatically with the useHistory hook).
This doesn't work for back button presses on the browser's controls (since that is something out of my control).
Is there an intermediate step before my Routes are rendered (or any browser navigation happens) that I can hook into, to make sure my resetSearch function is invoked?
As requested, here is the code:
// App.js
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const resetSearch = () => {
setResults([]);
setQuery("");
};
<SearchContext.Provider value={{ resetSearch }}>
// all of my <Route /> components in this block
</SearchContext.Provider>
// BusRoute.js
const { resetSearch } = useContext(SearchContext);
const handleClick = stop => {
resetSearch();
history.push(`/stops/${stop}`);
};
return (
// some other JSX
<button onClick={() => handleClick(stop.code)}>{stop.name}</button>
);

You can listen to history changes:
useEffect(() => {
const unlisten = history.listen((location) => {
console.log('new location: ', location)
// do your magic things here
// reset the search: clear the results and the search input field
})
return function cleanup() {
unlisten()
}
}, [])
You can use this effect in your parent component which controls your global search's value.

You can use componentWillUnmount feature from class components with useEffect in hooks with functional component

Related

How to make react components communicate each others in large component tree

In my react app, I have some complex component tree.
In this component tree, I have a <Footer/> component with buttons. I also have <SomeComponent/> component elsewhere in the tree. This component is actually loaded from some dynamic code and is not always the same (similar to some widget engine, where the container is handled by the app engine, and the content is dynamically loaded). It means the context has no knowledge of what are actually the components.
In order to plug everything else, I have a custom react context that holds some fields and methods, which is exposed trough a custom useMyContext hook.
This is working quite well except one remaining issue :
In my <Footer /> I have a button that should call something inside the <SomeComponent/> component. As an example I may have a 'Refresh' button that should ask the component to get latest data.
Basically I have this react tree:
App
SomeContextProvider
Footer
RefreshButton
Deep/Nested/Component/Structure
SomeComponent
(contains a refresh function)
How can I call the refresh function in my component from the footer ?
I tried to play with forwarding refs and useImperativeHandler hook, which may work, but the deep nesting of component tree leads to a big mess of forwarding refs.
I also tried to extend the context provider, but I didn't found a way to "reverse" the callback (context can react to Refresh button action, but I cannot react to this in sibling branch of the component tree).
How could I handle this ?
PS: if it matters, I'm using react 16.13.1 and typescript 4.5
I think I have the start of a clean solution.
Basically, I can handle my scenario by implementing a subscribe/unsubscribe pattern hold by by app context.
This way I can emit some kind of event from my outer context, and let components in the tree subscribe and handle the events as needed.
Some repro : https://codesandbox.io/s/infallible-chaplygin-79c3gq?file=/src/App.tsx.
Relevant parts below:
Custom react context
type Subscribe = (cb: () => void) => () => void;
type AppContextData = {
subscribe: Subscribe;
onSubmit: () => void;
};
const AppContext = createContext<AppContextData | undefined>(undefined);
const useAppContext = (): AppContextData => {
const context = useContext(AppContext);
if (!context)
throw new Error(`useAppContext must be used within a AppContextProvider`);
return context;
};
Container component
const AppContextProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => {
const subscribtions: (() => void)[] = [];
const subscribe: Subscribe = (cb) => {
subscribtions.push(cb);
return () => {
subscribtions.splice(subscribtions.indexOf(cb), 1);
};
};
const emitSubmit = () => {
subscribtions.forEach((cb) => cb());
};
const appContext: AppContextData = {
subscribe,
onSubmit: emitSubmit
};
return (
<AppContext.Provider value={appContext}>{children}</AppContext.Provider>
);
};
App
export default function App() {
return (
<AppContextProvider>
<div className="App">
<Main />
<Footer />
</div>
</AppContextProvider>
);
}
And finally, subscription and submission trigger:
Component with button
const Footer: React.VFC = () => {
const { onSubmit } = useAppContext();
return <button onClick={onSubmit}>Submit</button>;
};
Component that subscribes (and unsubscribe thanks to react effect)
const Main: React.VFC = () => {
const [myString, setMyString] = useState("initial");
const context = useAppContext();
useEffect(() => {
return context.subscribe(() => setMyString("from context"));
}, [context]);
return <p>{myString}</p>;
};

Excessive rerendering when interacting with global state in React Context

I'm building a Chat app, I'm using ContextAPI to hold the state that I'll be needing to access from different unrelated components.
A lot of rerendering is happening because of the context, everytime I type a letter in the input all the components rerender, same when I toggle the RightBar which its state also resides in the context because I need to toggle it from a button in Navbar.
I tried to use memo on every components, still all the components rerender everytime I interact with state in context from any component.
I added my whole code simplified to this sandbox link : https://codesandbox.io/s/interesting-sky-fzmc6
And this is a deployed Netlify link : https://csb-fzmc6.netlify.app/
I tried to separate my code into some custom hooks like useChatSerice, useUsersService to simplify the code and make the actual components clean, I'll also appreciate any insight about how to better structure those hooks and where to put CRUD functions while avoiding the excessive rerendering.
I found some "solutions" indicating that using multiple contexts should help, but I can't figure out how to do this in my specific case, been stuck with this problem for a week.
EDIT :
The main problem here is a full rerender with every letter typed in the input.
The second, is the RightBar toggle button which also causes a full rerender.
Splitting the navbar and chat state into two separate React contexts is actually the recommended method from React. By nesting all the state into a new object reference anytime any single state updated it necessarily triggers a rerender of all consumers.
<ChatContext.Provider
value={{ // <-- new object reference each render
rightBarValue: [rightBarIsOpen, setRightBarIsOpen],
chatState: {
editValue,
setEditValue,
editingId,
setEditingId,
inputValue,
setInputValue,
},
}}
>
{children}
</ChatContext.Provider>
I suggest carving rightBarValue and state setter into its own context.
NavBar context
const NavBarContext = createContext([false, () => {}]);
const NavBarProvider = ({ children }) => {
const [rightBarIsOpen, setRightBarIsOpen] = useState(true);
return (
<NavBarContext.Provider value={[rightBarIsOpen, setRightBarIsOpen]}>
{children}
</NavBarContext.Provider>
);
};
const useNavBar = () => useContext(NavBarContext);
Chat context
const ChatContext = createContext({
editValue: "",
setEditValue: () => {},
editingId: null,
setEditingId: () => {},
inputValue: "",
setInputValue: () => {}
});
const ChatProvider = ({ children }) => {
const [inputValue, setInputValue] = useState("");
const [editValue, setEditValue] = useState("");
const [editingId, setEditingId] = useState(null);
const chatState = useMemo(
() => ({
editValue,
setEditValue,
editingId,
setEditingId,
inputValue,
setInputValue
}),
[editValue, inputValue, editingId]
);
return (
<ChatContext.Provider value={chatState}>{children}</ChatContext.Provider>
);
};
const useChat = () => {
return useContext(ChatContext);
};
MainContainer
const MainContainer = () => {
return (
<ChatProvider>
<NavBarProvider>
<Container>
<NavBar />
<ChatSection />
</Container>
</NavBarProvider>
</ChatProvider>
);
};
NavBar - use the useNavBar hook
const NavBar = () => {
const [rightBarIsOpen, setRightBarIsOpen] = useNavBar();
useEffect(() => {
console.log("NavBar rendered"); // <-- log when rendered
});
return (
<NavBarContainer>
<span>MY NAVBAR</span>
<button onClick={() => setRightBarIsOpen(!rightBarIsOpen)}>
TOGGLE RIGHT-BAR
</button>
</NavBarContainer>
);
};
Chat
const Chat = ({ chatLines }) => {
const { addMessage, updateMessage, deleteMessage } = useChatService();
const {
editValue,
setEditValue,
editingId,
setEditingId,
inputValue,
setInputValue
} = useChat();
useEffect(() => {
console.log("Chat rendered"); // <-- log when rendered
});
return (
...
);
};
When running the app notice now that "NavBar rendered" only logs when toggling the navbar, and "Chat rendered" only logs when typing in the chat text area.
I recommend use jotai or other state management libraries.
Context is not suitable for high-frequency changes.
And, the RightBar's state looks can separate to other hook/context.
There is tricky one solution solve some render problems:
https://codesandbox.io/s/stoic-mclaren-x6yfv?file=/src/context/ChatContext.js
Your code needs to be refactored, and useChatService in ChatSection also depends on your useChat, so ChatSection will re-render when the text changes.
It looks like you are changing a global context on input field data change. If your global context is defined on a level of parent components (in relation to your input component), then the parent and all children will have to re-render.
You have several options to avoid this behavior:
Use context on a lower level, e.g. by extracting your input field to an external component and using useContext hook there
Save the input to local state of a component and only sync it to the global context on blur or submit

How do I call props.history.push() if I'm destructuring my props?

If I've got a function that creates a confirm popup when you click the back button, I want to save the state before navigating back to the search page. The order is a bit odd, there's a search page, then a submit form page, and the summary page. I have replace set to true in the reach router so when I click back on the summary page it goes to the search page. I want to preserve the history and pass the state of the submitted data into history, so when I click forward it goes back to the page without error.
I've looked up a bunch of guides and went through some of the docs, I think I've got a good idea of how to build this, but in this component we're destructuring props, so how do I pass those into the state variable of history?
export const BaseSummary = ({successState, children}: BaseSummaryProps) => {
let ref = createRef();
const [pdf, setPdf] = useState<any>();
const [finishStatus, setfinishStatus] = useState(false);
const onBackButtonEvent = (e) => {
e.preventDefault();
if (!finishStatus) {
if (window.confirm("Your claim has been submitted, would you like to exit before getting additional claim information?")) {
setfinishStatus(true);
props.history.push(ASSOCIATE_POLICY_SEARCH_ROUTE); // HERE
} else {
window.history.pushState({state: {successState: successState}}, "", window.location.pathname);
setfinishStatus(false);
}
}
};
useEffect(() => {
window.history.pushState(null, "", window.location.pathname);
window.addEventListener('popstate', onBackButtonEvent);
return () => {
window.removeEventListener('popstate', onBackButtonEvent);
};
}, []);
Also I'm not passing in the children var because history does not clone html elements, I just want to pass in the form data that's returned for this component to render the information accordingly
first of all, I think you need to use "useHistory" to handling your hsitry direct without do a lot of complex condition, and you can check more from here
for example:
let history = useHistory();
function handleClick() {
history.push("/home");
}
now, if you need to pass your history via props in this way or via your code, just put it in function and pass function its self, then when you destruct you just need to write your function name...for example:
function handleClick() {
history.push("/home");
}
<MyComponent onClick={handleClick} />
const MyComponent = ({onClick}) => {....}
I fixed it. We're using reach router, so everytime we navigate in our submit forms pages, we use the replace function like so: {replace: true, state: {...stateprops}}. Then I created a common component that overrides the back button functionality, resetting the history stack every time i click back, and using preventdefault to stop it from reloading the page. Then I created a variable to determine whether the window.confirm was pressed, and when it is, I then call history.back().
In some scenarios where we went to external pages outside of the reach router where replace doesn't work, I just used window.history.replaceStack() before the navigate (which is what reach router is essentially doing with their call).
Anyways you wrap this component around wherever you want the back button behavior popup to take effect, and pass in the successState (whatever props you're passing into the current page you're on) in the backButtonBehavior function.
Here is my code:
import React, {useEffect, ReactElement} from 'react';
import { StateProps } from '../Summary/types';
export interface BackButtonBehaviorProps {
children: ReactElement;
successState: StateProps;
}
let isTheBackButtonPressed = false;
export const BackButtonBehavior = ({successState, children}: BackButtonBehaviorProps) => {
const onBackButtonEvent = (e: { preventDefault: () => void; }) => {
e.preventDefault();
if (!isTheBackButtonPressed) {
if (window.confirm("Your claim has been submitted, would you like to exit before getting additional claim information?")) {
isTheBackButtonPressed = true;
window.history.back();
} else {
isTheBackButtonPressed = false;
window.history.pushState({successState: successState}, "success page", window.location.pathname); // When you click back (this refreshes the current instance)
}
} else {
isTheBackButtonPressed = false;
}
};
useEffect(() => {
window.history.pushState(null, "", window.location.pathname);
window.addEventListener('popstate', onBackButtonEvent);
return () => {
window.removeEventListener('popstate', onBackButtonEvent);
};
}, []);
return (children);
};

Does a React Context update on history.push('/')

I'm using a React Context const Context = React.createContext() with a useEffect hook to set a variable in my outer-most component that wraps my entire app. On a child component within my app I am using history.push('/') to route back to the root. This does not appear to trigger an update on my Context variable. Is this expected? If so, is there a better way to route that would update my context variable?
I'm using react 16.14.0 & react-router-dom 5.2.0.
For example, in the code below. Shouldnt var increment on history.push('/')
Context.js
const Context= React.createContext();
const ContextProvider= (props) => {
const [var, setVar] = useState(null);
useEffect(() => {
setVar(var++)
}, []);
return (
<Context.Provider value={user}>{props.children}</Context.Provider>
);
};
ChildComponent.js
...
import { useHistory } from "react-router-dom";
...
const ChildComponent = () => {
const history = useHistory();
function doSomething(){
history.push('/')
}
}
return(
<Button onClick={() => doSomething()} />
)
This does not appear to trigger an update on my Context variable. Is this expected?
Changing the history does not cause context providers to rerender. You mention you have a useEffect, and in principle you could write some code in that useEffect which would listen to the history, and when it gets a change it sets state to cause a rerender. If you have code in there that you think is supposed to listen for history changes, feel free to share that and i'll comment on it.
However, instead of writing your own code to listen for changes, i'd recommend using the hooks provided by react-router. The useHistory and useLocation hooks will both listen for changes and rerender the component.
const Example = () => {
// When the location changes, Example will rerender
const location = useLocation();
const [someState, setSomeState] = useState('foo');
useEffect(() => {
if (/* check something you care about in the location */) {
setSomeState('bar');
}
}, [location]);
return (
<Context.Provider value={someState}>
{children}
</Context.Provider>
)
}

Fetching w/ custom React hook based on router parameters

I'm trying to fetch data with a custom React hook, based on the current router parameters.
https://stackblitz.com/edit/react-fetch-router
What it should do:
On first load, check if URL contains an ID...
If it does, fetch a todo with that ID
If it does not, fetch a todo with a random ID & add ID to url
On fetch button clicks...
Fetch a todo with a random ID & add ID to url
What is wrong:
Watching the console or inspector network tab, you can see that it's firing several fetch requests on each click - why is this and how should this be done correctly?
Since you used history.push on handleClick, you will see multiple requests being sent as you are using history.push on click handler which will cause a re-render and make use use random generated url as well as also trigger the fetchTodo function.
Now a re-render will occur which is cause a randomised id to be generated. and passed onto useTodo hook which will lead to the fetchTodo function being called again.
The correct solution for you is to set the random todo id param on handleClick and also avoid unnecessary url updates
const Todos = () => {
const history = useHistory();
const { id: todoId } = useParams();
const { fetchTodo, todo, isFetching, error } = useTodo(todoId);
const isInitialRender = useRef(true);
const handleClick = () => {
const todoId = randomMax(100);
history.push(`/${todoId}`);
};
useEffect(() => {
history.push(`/${todoId}`);
}, []);
useEffect(() => {
if(!isInitialRender.current) {
fetchTodo();
} else {
isInitialRender.current = false
}
}, [todoId])
return (
<>
<button onClick={handleClick} style={{marginBottom: "10px"}}>Fetch a todo</button>
{isFetching ? (
<p>Fetching...</p>
) : (
<Todo todo={todo} color={todo.color} isFetching={isFetching} />
)
}
</>
);
};
export default Todos;
Working demo

Resources