history.push sometimes triggers refresh sometimes not - reactjs

Using React 17 and React Router Dom 5.2 I've encountered a strange phenomenon.
In a simply CRUD application, there is a component for listing up all items, one for creating, and one for editing.
A little bit simplified, the code looks like this:
parent:
<Switch>
<Route path="/item/create" component={ItemCreatePage}></Route>
<Route path="/item/:itemId" component={ItemEditPage}></Route>
<Route path="/item" component={ItemList}></Route>
</Switch>
create:
import React from "react"
import { useHistory } from "react-router-dom"
import { ItemCreateOrEditComponent } from "./item-create-and-edit"
import { CreateItemInput, useCreateItemMutation } from "/types"
export const ItemCreatePage: React.FC = () => {
const history = useHistory()
const [createItemMutation] = useCreateItemMutation()
async function createItem(itemToSave: CreateItemInput) {
await createItemMutation({ variables: { data: itemToSave } })
history.push(`/item`)
}
return <ItemCreateOrEditComponent createOrEdit="create" saveFunction={createItem}></ItemCreateOrEditComponent>
}
edit:
import React, { useState } from "react"
import { RouteComponentProps, useHistory } from "react-router-dom"
import { ItemCreateOrEditComponent } from "../../../components/form/item-create-and-edit"
import { UpdateItemInput, useItemQuery, useUpdateItemMutation } from "/types"
type Props = RouteComponentProps<{ itemId: string }>
export const ItemEditPage: React.FC<Props> = (props) => {
const itemId = props.match.params.itemId
const [item, setItem] = useState<UpdateItemInput | undefined>(undefined)
const { data } = useItemQuery({ variables: { id: itemId } })
const history = useHistory()
const [updateItemMutation] = useUpdateItemMutation()
React.useEffect(() => {
if (data?.item) {
setItem(data.item)
}
}, [data])
async function updateItem(itemToSave: UpdateItemInput) {
await updateItemMutation({ variables: { data: itemToSave, }, })
history.push(`/item`)
}
return <ItemCreateOrEditComponent inputItem={item} createOrEdit="edit" saveFunction={updateItem}></ItemCreateOrEditComponent>
}
So, create and edit do basically the same things: Use ItemCreateOrEditComponent, trigger a DB function for saving (Apollo GraphQL in this case, but doesn't matter) and then go back to the list: history.push("/item").
However, the strange thing is: After editing, the list is refreshed with the updated item. After creating, the list is not updated.
It's not a race condition, adding a few seconds of timeout before history.push("/item") doesn't change anything.
The only differences between creating and editing I see are
For editing, the data gets fetched from the back-end
For editing, there is a URL prop containing the items id
I cannot image why these two things should have any impact on whether a refresh is triggered or not.
Edit:
ItemCreateOrEditComponent provides a form, does some validation and transformation, and then finally calls this.props.saveFunction(this.state.item) in the exact same way both for edit and create. And till it comes to history.push I cannot see any difference in behavior there.

Related

Reducer state update causing a router wrapped in HOC to rerender in a loop

I found that the issue is stemming from a Higher Order Component that wraps around a react-router-dom hook.
This Higher Order Component is imported from #auth0/auth0-react and is a requirement in our project to handle logging out with redirect.
However, even just a basic HOC, the issue is persisting.
in my App.js file, I have a react-redux provider. And inside the provider I have a ProtectLayout component.
ProtectLayout checks for an error reducer, and if the error property in the reducer has a value, it sets a toast message, as seen below.
import React, { useEffect } from "react";
import { useSelector } from "react-redux";
import Loadable from "react-loadable";
import { Switch } from "react-router-dom";
import PageLoader from "../loader/PageLoader";
import { useToast } from "../toast/ToastContext";
import { selectError } from "../../store/reducers/error/error.slice";
import ProtectedRoute from "../routes/ProtectedRoute";
const JobsPage = Loadable({
loader: () => import("../../screens/jobs/JobsPage"),
loading: () => <PageLoader loadingText="Getting your jobs..." />
});
const ProtectedLayout = () => {
const { openToast } = useToast();
const { error } = useSelector(selectError);
const getErrorDetails = async () => {
if (error) {
if (error?.title || error?.message)
return { title: error?.title, message: error?.message };
return {
title: "Error",
message: `Something went wrong. We couldn't complete this request`
};
}
return null;
};
useEffect(() => {
let isMounted = true;
getErrorDetails().then(
(e) =>
isMounted &&
(e?.title || e?.message) &&
openToast({ type: "error", title: e?.title, message: e?.message })
);
return () => {
isMounted = false;
};
}, [error]);
return (
<Switch>
<ProtectedRoute exact path="/" component={JobsPage} />
</Switch>
);
};
export default ProtectedLayout;
ProtectLayout returns another component ProtectedRoute. ProtectedRoute renders a react-router-dom Route component, which the component prop on the Route in the component prop passed into ProtectedRoute but wrapped in a Higher Order Component. In my actual application, as aforementioned, this is the withAuthenticationRequired HOC from #auth0/auth0-react which checks if an auth0 user is logged in, otherwise it logs the user out and redirects to the correct URL.
import React from "react";
import { Route } from "react-router-dom";
const withAuthenticationRequired = (Component, options) => {
return function WithAuthenticationRequired(props) {
return <Component {...props} />;
};
};
const ProtectedRoute = ({ component, ...args }) => {
return <Route component={withAuthenticationRequired(component)} {...args} />;
};
export default ProtectedRoute;
However, in one of the Route components, JobsPage the error reducer state is updated on mount, so what happens is the state gets updated, the ProtectedLayout re-renders, which then re-renders ProtectedRoute, which then re-renders JobPage which triggers the useEffect again, which updates the state, so you end up in an infinite loop.
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import { getGlobalError } from "../../store/reducers/error/error.thunk";
const JobsPage = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(getGlobalError(new Error("test")));
}, []);
return (
<div>
JOBS PAGE
</div>
);
};
export default JobsPage;
I have no idea how to prevent this rendering loop?
Really all I want to do, is that when there is an error thrown in a thunk action, it catches the error and updates the error reducer state. That will then trigger a toast message, using the useToast hook. Perhaps there is a better way around this, that what I currently have setup?
I have a CodeSandbox below to recreate this issue. If you click on the text you can see the re-renders occur, if you comment out the useEffect hook, it will basically crash the sandbox, so might be best to only uncomment when you think you have resolved the issue.
Any help would be greatly appreciated!

Invalid hook call error when showing/hiding component with React Redux

I'm attempting to create a React/Redux component that shows/hides an element when clicked.
I'm using this to trigger the function from another component:
import React from 'react'
//Some other code...
import { useSelector } from 'react-redux'
import onShowHelpClicked from '../help/AddHelpSelector'
<button onClick={onShowHelpClicked}>Help</button>
This this is AddHelpSelector:
import { useState } from 'react'
import { useDispatch } from 'react-redux'
import { helpVisible } from './HelpSlice'
export const AddHelp = () => {
const [isVisible, showHelp] = useState('')
const dispatch = useDispatch()
const onShowHelpClicked = () => {
dispatch(
helpVisible({
isVisible,
})
)
if (isVisible) {
showHelp(false)
} else {
showHelp(true)
}
}
return (
<section>
<h2 style={{ visibility: { isVisible } }}>Help section</h2>
</section>
)
}
export default AddHelp
Finally, this is HelpSlice
import { createSlice } from '#reduxjs/toolkit'
const initialState = [{ isVisible: false }]
const helpSlice = createSlice({
name: 'help',
initialState,
reducers: {
helpVisible(state, action) {
state.push(action.payload)
},
},
})
export const { helpVisible } = helpSlice.actions
export default helpSlice.reducer
I'm fairly certain I'm doing multiple things wrong, as this is my first attempt to do anything with Redux and I'm still struggling to wrap my mind around it after a week of learning.
But specifically, when clicking the help button I get this error.
"Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component.
You might have mismatching versions of React and the renderer (such as React DOM)
You might be breaking the Rules of Hooks
You might have more than one copy of React in the same app"
The linked documentation provides a way to test a component to see if React is importing properly, and it's not. But I'm not really sure what I'm doing wrong.
I think it may be that I'm importing React multiple times, but if I don't then I can't use "useState."
What's the correct way to do this? I'm open to corrections on both my code as well as naming conventions. I'm using boilerplate code as a template as I try to understand this better after getting through the documentation as well as Mosh's 6 hour course which I just finished.
You're importing the < AddHelpSelector /> component here import onShowHelpClicked from '../help/AddHelpSelector', and then you try to use it as a callback handler for the button's onClick, which doesn't really make sense. I assume you actually wanted to only import the onShowHelpClicked function declared inside the < AddHelpSelector /> component (which is not really a valid way of doing it). Since you want to control the visibility using redux state, you could just grab the flag from the redux store inside the < AddHelpSelector /> component using useSelector hook. To set it, you're gonna do that in the component where your button is. For that, you just need to dispatch an action(like you already did), with the updated flag. No need for the local useState. Also, using the flag you could just conditionally render the element.
const App = () => {
const dispatch = useDispatch();
const { isVisible } = useSelector((state) => ({ isVisible: state.isVisible }));
const handleClick = () => {
dispatch(
helpVisible({
!isVisible,
})
)
}
return (<button onClick={handleClick}>Help</button>);
}
export const AddHelp = () => {
const { isVisible } = useSelector((state) => ({ isVisible: state.isVisible }));
return (
<section>
{isVisible && <h2>Help section</h2>}
</section>
)
}
export default AddHelp

React router v6 history.listen

In React Router v5 there was a listen mehtode on the history object.
With the method I wrote a usePathname hook to rerender a component on a path change.
In React Router v6, this method no longer exists. Is there an alternative for something like this? I would hate to use useLocation because it also renders if the state changes, which I don't need in this case.
The hook is used with v5.
import React from "react";
import { useHistory } from "react-router";
export function usePathname(): string {
let [state, setState] = React.useState<string>(window.location.pathname);
const history = useHistory();
React.useLayoutEffect(
() =>
history.listen((locationListener) => setState(locationListener.pathname)),
[history]
);
return state;
}
As mentioned above, useLocation can be used to perform side effects whenever the current location changes.
Here's a simple typescript implementation of my location change "listener" hook. Should help you get the result you're looking for
function useLocationEffect(callback: (location?: Location) => any) {
const location = useLocation();
useEffect(() => {
callback(location);
}, [location, callback]);
}
// usage:
useLocationEffect((location: Location) =>
console.log('changed to ' + location.pathname));
I am using now this code
import { BrowserHistory } from "history";
import React, { useContext } from "react";
import { UNSAFE_NavigationContext } from "react-router-dom";
export default function usePathname(): string {
let [state, setState] = React.useState<string>(window.location.pathname);
const navigation = useContext(UNSAFE_NavigationContext)
.navigator as BrowserHistory;
React.useLayoutEffect(() => {
if (navigation) {
navigation.listen((locationListener) =>
setState(locationListener.location.pathname)
);
}
}, [navigation]);
return state;
}
It seems to work fine
I find using useNavigate and useLocation quite meaningless compared to useHistory in React Rrouter v5.
As a result of these changes, I made a thin custom hook to ease myself from any refactoring.
Just rename the import path to this hook and use the "old" api with v6. To answer or just give hints to your question - using this approach is should be easy to implement the listen function in the custom hook yourself.
export function useHistory() {
const navigate = useNavigate();
const location = useLocation();
const listen = ...; // implement the hook yourself
return {
push: navigate,
go: navigate,
goBack: () => navigate(-1),
goForward: () => navigate(1),
listen,
location,
};
}
Why not simply use const { pathname } = useLocation();? It will indeed renders if the state changes but it shouldn't be a big deal in most scenarii.
If you REALLY want to avoid such behaviour, you could create a context of your own to hold the pathname:
// PathnameProvider.js
import React, { createContext, useContext } from 'react';
import { useLocation } from 'react-router';
const PathnameContext = createContext();
const PathnameProvider = ({ children }) => {
const { pathname } = useLocation();
return (
<PathnameContext.Provider value={pathname}>
{children}
</PathnameContext.Provider>
);
}
const usePathname = () => useContext(PathnameContext);
export { PathnameProvider as default, usePathname };
Then you can use usePathname() in any component down the tree. It will render only if the pathname actually changed.
Given that #kryštof-Řeháček's recommendation (just above) is to implement your own useListen hook, but it might not be obvious how to do that, here's a version I've implemented for myself as a guide (nb: I havent't exhaustively unit tested this yet):
import { useState } from "react";
import { useLocation } from "react-router";
interface HistoryProps {
index: number;
isHistoricRoute: boolean;
key: string;
previousKey: string | null;
}
export const useHistory = (): HistoryProps => {
const { key } = useLocation();
const [history, setHistory] = useState<string[]>([]);
const [currentKey, setCurrentKey] = useState<string | null>(null);
const [previousKey, setPreviousKey] = useState<string | null>(null);
const contemporaneousHistory = history.includes(key)
? history
: [...history, key];
const index = contemporaneousHistory.indexOf(key);
const isHistoricRoute = index + 1 < contemporaneousHistory.length;
const state = { index, isHistoricRoute, key, previousKey };
if (history !== contemporaneousHistory) setHistory(contemporaneousHistory);
if (key !== currentKey) {
setPreviousKey(currentKey);
setCurrentKey(key);
}
return state;
}
I now have just created a new routing library for react where this is possible.
https://github.com/fast-router/fast-router
Server-Side rendering is not supported. The rest should work fine. The library is mainly inspired by wouter -> https://github.com/molefrog/wouter
There are hooks for example usePathname which only cause a new render if the actual pathname changes (ignoring the hash and search)
It is possible to select just a single property of the history.state and don't get a new render if any other values inside the state changes.

React hooks useState getting diferrent value from redux state

I have react component look like this following code:
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link, useParams } from "react-router-dom";
import { createClient, getClients } from "../redux/actions/clients";
function UpdateClient(props) {
let params = useParams();
const { error, successSubmit, clients } = useSelector(
(state) => state.clients
);
const [client, setClient] = useState(clients[0]);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getClients({ id: params.id }));
}, []);
const submitClient = () => {
dispatch(createClient(client));
};
return (
<div>{client.name} {clients[0].name}</div>
);
}
export default UpdateClient;
And the result is different client.name return test1,
while clients[0].name return correct data based on route parameter id (in this example parameter id value is 7) which is test7
I need the local state for temporary saving form data. I don't know .. why it's become different?
Can you please help me guys? Thanks in advance
You are referencing a stale state which is a copy of the clients state.
If you want to see an updated state you should use useEffect for that.
useEffect(() => {
setClient(clients[0]);
}, [clients]);
Notice that duplicating state is not recommended.
There should be a single “source of truth” for any data that changes in a React application.

trying to use cookies in my react app but it gets re-rendered all the time

I have a react app with sign in/out functionality. I want the application to keep logged in after reloading, and for that I'm using cookies.
The problem is that after I implemented what I thought should work, I get a "warning, maximum update depth exceeded. it can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render" message.
What am I doing wrong?
Thanks a lot!!
Here is the code.
sessions.ts
import React from "react";
import * as Cookies from "js-cookie";
export const setSessionCookie = (session: any): void => {
Cookies.remove("session");
Cookies.set("session", session, { expires: 14 });
};
export const getSessionCookie: any = () => {
const sessionCookie = Cookies.get("session");
if (sessionCookie === undefined) {
return {};
} else {
return JSON.parse(sessionCookie);
}
};
export const SessionContext = React.createContext(getSessionCookie());
main.tsx
import React, { useState, useEffect } from "react";
import { Switch, Route, Router } from "react-router-dom";
import Comp1 from "./components";
import Comp2 from "./components";
import { getSessionCookie, SessionContext } from "./session";
import { createBrowserHistory } from "history";
export default function Main() {
const [session, setSession] = useState(getSessionCookie());
useEffect(() => {
setSession(getSessionCookie());
}, [session]);
const history = createBrowserHistory();
return (
<SessionContext.Provider value={session}>
<Router history={history}>
<Switch>
<Route exact path="/" component={Comp1} />
<Route path="/comp2" component={Comp2} />
</Switch>
</Router>
</SessionContext.Provider>
);
}
I think the problem is that inside your useEffect you are changing the session, and then watching for session changes. You could probably compare the new cookie and only update the session if the cookie has changed -
useEffect(() => {
const cookie = getSessionCookie();
if (cookie !== session) setSession(cookie);
}, [session]);
Not sure exactly what your cookie looks like - you may have to do a more complex comparison than !==.
const [session, setSession] = useState(getSessionCookie());
You get session
useEffect(() => { ... }, [session]);
Session changed, effect callback called.
setSession(getSessionCookie());
We are setting a new session here (note that getSessionCookie always returns a new object). Component will re-render.
Back to the top.

Resources