In my parent component, Dashboard.tsx, I have a child component, Expenses.tsx, that makes an API fetch call and then displays the data. The parent component has a Router that allows you to navigate to different URL's in the parent component, which forces everything to re-render every time you navigate to a new path or render a new child component. How can I make it so that this fetch call is only made one time? I've tried using the useRef() hook but it re-initializes every time there is a re-render and I have the same problem.
Here is Dashboard.tsx:
export const Dashboard = () => {
const d = new Date()
const history = useHistory()
const { user, setUser } = useAuth()
const [categories, setCategories] = useState({
expenseCategories: [],
incomeCategories: []
})
const getCategories = async(user_id: number) => {
await fetch(`/api/getCategories?user_id=${user_id}`)
.then(result => result.json())
.then(result => setCategories(result))
}
useEffect(() => {
if (user.info.user_id) {
getCategories(user.info.user_id)
}
}, [])
const dashboardItems = [
{
value: 'Add Expense',
path: '/dashboard/addExpense'
},
{
value: 'Add Income',
path: '/dashboard/addIncome'
},
{
value: 'Logout',
path: '/login',
onClick : async() => {
localStorage.clear()
setUser({
info: {
user_id: null,
email: null,
username: null
},
token: null
})
},
float: 'ml-auto'
}
]
return(
<div>
<DashboardNavbar items={dashboardItems}/>
<div className="wrapper">
<p>{`Hello, ${user.info.username}!`}</p>
<DateAndTime />
<Expenses date={d}/>
<Income date={d}/>
<Switch>
<Route path='/dashboard/addExpense'>
<AddItemForm user={user} type={'expenses'} categories={categories.expenseCategories} />
</Route>
<Route path='/dashboard/addIncome'>
<AddItemForm user={user} type={'income'} categories={categories.incomeCategories} />
</Route>
</Switch>
<Logout />
</div>
</div>
)
}
And here is Expenses.tsx, where the fetch call is being made:
export const Expenses = (props: ExpensesProps) => {
const [isLoading, setIsLoading] = useState(true)
const { date } = props
const { user } = useAuth()
const m = date.getMonth() + 1
const s = '0'.concat(m.toString())
const [total, setTotal] = useState<number>(0)
useEffect(() => {
const getTotalExpenses = async() => {
await fetch(`/api/expenses?user_id=${user.info.user_id}&month=${s}`)
.then(response => response.json())
.then(result => {
if (result) {
setTotal(parseFloat(result))
}
})
.then(result => {
setIsLoading(false)
})
}
if (user.info.user_id) {
getTotalExpenses()
}
}, [])
return isLoading ? (
<div>
loading...
</div>
) : (
<div>
{`Your monthly expenses so far are: $${total}.`}
</div>
)
}
Related
The bounty expires in 7 days. Answers to this question are eligible for a +500 reputation bounty.
Ludwig is looking for an answer from a reputable source.
I have a context provider in my app:
export const FormContext = createContext<IFormContext | null>(null);
function FormProvider({ caseNumber, children, ...props }: PropsWithChildren<IFormProviderContextProps>) {
const {
data: { caseNumber, taxDocuments, roles },
api,
} = useApiData();
const [error, setError] = useState<string>(null);
const [searchParams, setSearchParams] = useSearchParams();
const activeStep = searchParams.get("step");
const setActiveStep = useCallback((x: number) => {
searchParams.delete("steg");
setSearchParams([...searchParams.entries(), ["step", Object.keys(STEPS).find((k) => STEPS[k] === x)]]);
}, []);
useEffect(() => {
const abortController = new AbortController();
if (case) api.getPersons(case, abortController.signal).catch((error) => setError(error.message));
return () => {
abortController.abort();
};
}, [case]);
useEffect(() => {
const abortController = new AbortController();
if (activeStep === Stepper.INCOME) {
api.getTaxDocuments(abortController.signal).catch((error) => setError(error.message));
}
return () => {
abortController.abort();
};
}, [activeStep]);
useEffect(() => {
const abortController = new AbortController();
api.getCase(caseNumber, abortController.signal).catch((error) => setError(error.message));
}
return () => {
abortController.abort();
};
}, []);
return (
<FormContex.Provider value={{ taxDocuments, case, roles, activeStep, setActiveStep, error, ...props }}>
{children}
</FormContex.Provider>
);
}
I am using this FormProvider as a wrapper for my FormPage:
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/:caseNumber" element={<FormWrapper />} />
<Route path="/" element={<div>Hello world</div>} />
</Routes>
</BrowserRouter>
</React.StrictMode>
function FormWrapper() {
const { caseNumber } = useParams<{ caseNumber?: string }>();
return (
<FormProvider caseNumber={caseNumber}>
<FormPage />
</FormProvider>
);
}
In my FormPage I display components based on the activeStep that I get from FromProvider
export default function FormWrapper({ activeStep, ...props }: FormWrapperProps) {
const renderForm = useMemo(() => {
switch (activeStep) {
case Stepper.TIMELINE:
return <Timeline {...props} />;
case Stepper.INCOME:
return <Income {...props} />;
case Stepper.RESIDENCY:
return <Residency {...props} />;
case Stepper.SUMMARY:
return <Summary {...props} />;
default:
return <Timeline {...props} />;
}
}, [activeStep]);
return <Suspense fallback={<Loader size="3xlarge" title="loading..." />}>{renderForm}</Suspense>;
}
What I would like to do is to implement an abort controller if component gets unmounted to stop the fetch request and state update. I have tried that with implementing it inside useEffect functions of the FormProvider. But, that is repetitive and would like to make some kind of function or a hook that would set the abort controller to every request. I am not sure how to do that with the current setup, where I have my api calls defined in useApiData() hook which looks like this:
export const useApiData = () => {
const [case, setCase] = useState<CaseDto>(null);
const [taxDocuments, setTaxDocuments] = useState<TaxDocumentsResponse[]>([]);
const [roles, setRoles] = useState<IRoleUi[]>([]);
const getCase = async (caseNumber: string, signal?: AbortSignal) => {
const case = await CASE_API.case.findMetadataForCase(caseNumber, { signal });
setCase(case.data);
};
const getPersons = async (case: CaseDto, signal?: AbortSignal) => {
const personPromises = case.roles.map((role) =>
PERSON_API.information.getPersonPost(
{ id: role.id },
{ signal }
)
);
const [...persons] = await Promise.all([...personPromises]);
const roles = persons.map((person) => {
const role = case.roles.find((role) => role.id === person.data.id);
if (!role) throw new Error(PERSON_NOT_FOUND);
return { ...role, ...person.data };
});
setRoles(roles);
};
const getTaxDocuments = async (signal?: AbortSignal) => {
const taxDocumentsDtoPromises = [getFullYear() - 1, getFullYear() - 2, getFullYear() - 3].map((year) =>
TAX_API.integration.getTaxDocument(
{
year: year.toString(),
filter: "",
personId: "123",
},
{ signal }
)
);
const [taxDocument1, taxDocument2, taxDocument3] = await Promise.all([...taxDocumentsDtoPromises]);
setTaxDocuments([taxDocument1.data, taxDocument2.data, taxDocument3.data]);
};
const api = {
getCase,
getPersons,
getTaxDocuments,
};
const data = {
case,
roles,
taxDocuments,
};
return { data, api };
}
As I said I would like to be able to call api without having to define abort controller in every useEffect hook, but I am not sure how to achieve some like this for example:
apiWithAbortController.getCase(caseNumber).catch((error) => setError(error.message))}
I have tried with using a custom hook like this:
export const useAbortController = () => {
const abortControllerRef = useRef<AbortController>();
useEffect(() => {
return () => abortControllerRef.current?.abort();
}, []);
const getSignal = useCallback(() => {
if (!abortControllerRef.current) {
abortControllerRef.current = new AbortController();
}
return abortControllerRef.current.signal;
}, []);
return getSignal;
};
That I was using like this in my useApiData:
const signalAbort = useAbortController();
const getCase = async (caseNumber: string) => {
const case = await CASE_API.case.findMetadataForCase(caseNumber, { signal: signalAbort() });
setCase(case.data);
};
But, that didn't work, with that setup none of the fetch calls were made.
In my Main.tsx:
import React, { FC, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '../../hook'
import { getProducts } from '../../store/ProductsSlice'
import Filter from '../Filter/Filter'
import Pagination from '../Pagination/Pagination'
import Products from '../Products/Products'
import { ErrorMessage, FilterError } from './styled'
const Main: FC = () => {
const products = useAppSelector((state) => state.products.list)
const dispatch = useAppDispatch()
const [errorId, setErrorId] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
const [page, setPage] = useState<number>(1)
const [filterId, setFilterId] = useState<number>()
const [pageParams, setPageParams] = useSearchParams()
pageParams.get(`page`) || ''
pageParams.get(`id`) || ''
useEffect(() => {
async function fetchProducts(id?: number, productsPage = 1) {
const itemsPerPage = 5
let url: string
if (id) {
url = `https://reqres.in/api/products/${id}`
} else {
url = `https://reqres.in/api/pr231oducts?per_page=${itemsPerPage}&page=${productsPage}`
}
const requestOptions = {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}
fetch(url, requestOptions)
.then(async (response) => {
const data = await response.json()
if (response.ok) {
setErrorId('')
setErrorMessage('')
if (id) {
dispatch(
getProducts({
page: 1,
per_page: 1,
total: 1,
total_pages: 1,
data: [data.data],
})
)
setPageParams({ page: `1`, id: `${id}` })
} else {
dispatch(getProducts(data))
setPageParams({ page: `${productsPage}` })
}
} else {
const error = (data && data.message) || response.status
return Promise.reject(error)
}
setErrorMessage(data.id)
})
.catch((error) => {
setErrorId(error.toString())
console.error('There was an error!', error)
})
}
fetchProducts(filterId, page)
}, [filterId, page])
return (
<div>
{!products ? (
<>
{errorId ? <ErrorMessage>{errorId}</ErrorMessage> : null}
{errorMessage ? (
<ErrorMessage>
Something went wrong
{errorMessage}
</ErrorMessage>
) : null}
</>
) : (
<>
<Filter setFilterId={setFilterId} />
{errorId ? (
<FilterError>
{errorId}:
{errorId === '404'
? ' Product not found'
: `${errorId}: ${errorMessage}`}
</FilterError>
) : (
<Products />
)}
<Pagination setPage={setPage} />
</>
)}
</div>
)
}
export default Main
Filter.tsx:
import React, { FC } from 'react'
import { FilterContainer, FilterInput } from './styled'
const Filter: FC<{
setFilterId: React.Dispatch<React.SetStateAction<number | undefined>>
}> = ({ setFilterId }) => {
return (
<FilterContainer>
<FilterInput
onChange={(e) => {
if (e.target.value === '0') {
e.target.value = ''
}
setFilterId(Number(e.target.value))
}}
placeholder="Search by id"
type="number"
/>
</FilterContainer>
)
}
export default Filter
Pagination.tsx:
import { FC } from 'react'
import { useAppSelector } from '../../hook'
import ArrowBackIosIcon from '#mui/icons-material/ArrowBackIos'
import ArrowForwardIosIcon from '#mui/icons-material/ArrowForwardIos'
import { PaginationBtn, PaginationContainer } from './styled'
const Pagination: FC<{
setPage: React.Dispatch<React.SetStateAction<number>>
}> = ({ setPage }) => {
let pageNumber = useAppSelector((state) => state.products.list.page)
const totalPages = useAppSelector((state) => state.products.list.total_pages)
return (
<PaginationContainer>
<PaginationBtn
onClick={() => {
setPage((pageNumber -= 1))
}}
disabled={pageNumber <= 1}
>
<ArrowBackIosIcon fontSize="large" />
</PaginationBtn>
<PaginationBtn
onClick={() => {
setPage((pageNumber += 1))
}}
disabled={pageNumber >= totalPages}
>
<ArrowForwardIosIcon fontSize="large" />
</PaginationBtn>
</PaginationContainer>
)
}
export default Pagination
The fetchProducts function makes a request to the API, using the productPage and id variables passed to the function, the corresponding request is sent and the necessary information is displayed on the screen.
I'm going to take the page and id from the link and pass them to the fetchProducts function so that if something happens, the site opens immediately with the necessary information.
I have useSearchParams() with which I make a link that can be "sent to other users". But I don’t understand how to implement that when parameters are inserted into the link, they are applied and the page with the necessary data is loaded.
Now the correct link is generated, but if you copy it and paste it in another browser window, the standard "list of products" will be loaded
I have already an exemple for make you understand How to pass parameters from a URL link to a request:
App.js
function App() {
return (
<>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/add-book" element={<AddBook />} />
<Route path="/upd-book/:id" element={<UpdBook />} />
</Routes>
</BrowserRouter>
</>
)
}
export default App;
Home.js
<Link to={`/upd-book/${id}`} >Update</Link>
UpdatePage.js exemple url after you click to Link: localhost:3000/upd-book/30
import {useParams} from 'react-router-dom';
const {id} = useParams();
{id} is 30
I hope this exemple explain how this is work.
Issue
The Main component has competing "sources of truth", the queryString params and local state.
Solution
Use the queryString params as the source of truth for the API requests. Access the "page" and "id" query params in the component and pass as useEffect hook dependencies and on to the fetchProducts handler. Instead of enqueuing state updates enqueue navigation redirects that only update the URL queryString.
const Main: FC = () => {
const dispatch = useAppDispatch();
const products = useAppSelector((state) => state.products.list);
const [searchParams, setSearchParams] = useSearchParams();
// Read the queryString parameters, convert to number type
const page = Number(searchParams.get("page") || 1);
const filterId = Number(searchParams.get("id"));
const [errorId, setErrorId] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
useEffect(() => {
async function fetchProducts(id?: number, page: number = 1) {
const itemsPerPage = 5;
const url = id
? `https://reqres.in/api/products/${id}`
: `https://reqres.in/api/pr231oducts?per_page=${itemsPerPage}&page=${page}`;
const requestOptions = {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}
try {
const response = await fetch(url, requestOptions);
const data = await response.json();
if (response.ok) {
setErrorId('');
setErrorMessage('');
if (id) {
dispatch(
getProducts({
page: 1,
per_page: 1,
total: 1,
total_pages: 1,
data: [data.data],
})
);
setSearchParams({ page, id }, { replace: true });
} else {
dispatch(getProducts(data));
setPageParams({ page }, { replace: true });
}
} else {
const error = data?.message || response.status;
return Promise.reject(error);
}
setErrorMessage(data.id);
} catch(error) {
setErrorId(error.toString());
console.error('There was an error!', error);
}
};
fetchProducts(filterId, page);
}, [filterId, page]);
// Callbacks to update the queryString parameters
const setPage = page => setSearchParams(params => {
params.set("page", page);
return params;
}, { replace: true });
const setFilterId = id => setSearchParams(params => {
params.set("id", id);
return params;
}, { replace: true });
return (
<div>
{!products ? (
<>
{errorId ? <ErrorMessage>{errorId}</ErrorMessage> : null}
{errorMessage && (
<ErrorMessage>
Something went wrong
{errorMessage}
</ErrorMessage>
)}
</>
) : (
<>
<Filter setFilterId={setFilterId} />
{errorId ? (
<FilterError>
{errorId}:
{errorId === '404'
? "Product not found"
: `${errorId}: ${errorMessage}`
}
</FilterError>
) : (
<Products />
)}
<Pagination setPage={setPage} />
</>
)}
</div>
);
};
I have a problem with updating the state of a component after a redirect. Actually I have two components getItems.js and addItem.js
App.js
const [item, setItem] = useState([]);
useEffect(() => {
const fetch = () => {
axios.get('localhost:3000/api/get_all.php')
.then(response => {
setItems(response.data);
}).catch(error => {
console.log(error)
})
}
fetch();
}, [])
return (
<BrowserRouter>
<div className="container">
<Routes
<Route path="/" element={<getItems items={items}/>} />
<Route path='/add-item' element={<addItem />} />
</Routes>
</div>
</BrowserRouter>
)
getItems.js
This component is used to display each item from App.js
addItem.js
const onSubmit = (event) => {
const item = {
...
}
axios.post('localhost:3000/api/add_item.php', item)
.then(response => {
console.log(response.data);
})
.catch(error => {
console.log(error);
})
}
On App.js I have a button to direct to add-item page, once I save the item and redirect to App.js page the items state is not updated, only if I reload the entire page.
You need to set the state after adding an item. Since your logic is separated in two routes, you will have to use a contextProvider
Basically, it allows you to share state anywhere in your app.
ContextProvider.js
//Set initial value. We wont use it but we need t odefined it here
export const ContextProvider = createContext({
items: undefined
setItems: () => {}
});
App.js
export const App = () => {
const [items, setItems] = useState([])
//Pass state to context
const contextValue = {
items: items,
setItems: setItems,
};
useEffect(() => {
const fetch = () => {
axios.get('localhost:3000/api/get_all.php')
.then(response => {
setItems(response.data);
}).catch(error => {
console.log(error)
})
}
fetch();
}, [])
return (
<ContextProvider.Provider value={contextValue}>
<BrowserRouter>
<div className="container">
<Routes
<Route path="/" element={<getItems/>} />
<Route path='/add-item' element={<addItem />} />
</Routes>
</div>
</BrowserRouter>
</ContextProvider.Provider>
)
};
GetItems.js
//instead of prop.items
const {items} = useContext(Contextprovider)
addItem.js
const {items, setitems} = useContext(Contextprovider)
axios.post('localhost:3000/api/add_item.php', item)
.then(response => {
console.log(response.data);
//Validate if item is added
if(response.data) {
//Push new item to list
setItem([...item, response.data])
}
})
.catch(error => {
console.log(error);
})
I created a separate node.js restful API for checking if the user token is valid. I did this for my react protected routes, even though most of my endpoints already checks the user token.
My redux Thunk:
const verifyToken = () => {
return async (dispatch: Dispatch) => {
await axios
.get("http://localhost:9999/authenticate", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
})
.then(async (resp) => {
const { message } = await resp.data;
console.log(message);
if (message === "verified") dispatch({ type: "signIn-success" });
})
.catch((err) => {
console.log(err);
dispatch({ type: "logout-user" });
});
};
};
Protected Route:
const ProtectedRoute: FC<{ children: ReactNode; path: string }> = ({
children,
path,
}) => {
const dispatch = useDispatch();
dispatch(verifyToken()); //dispatch thunk
const status = useSelector((store: RootStore) => store.status)
return status && status === "loggedOut" ? (
<Redirect to="/" />
) : (
<Route path={path} render={() => <>{children}</>} />
);
};
The problem is that it takes time to dispatch my thunk, so it redirects to "/" even though the user token checking hasn't finished.
So what happens is, the value of status is "loggedOut" at first then my thunk will check if the token is valid. If it is, status becomes "loggedIn", but its too late because it already redirected to "/"
You need a loading for the time you're dispatching the action, something like this:
const ProtectedRoute: FC<{ children: ReactNode, path: string }> = ({
children,
path,
}) => {
const [loading, setLoading] = useState(true);
const dispatch = useDispatch();
const status = useSelector((store: RootStore) => store.status);
async function verify() {
await dispatch(verifyToken()); //dispatch thunk
setLoading(false);
}
useEffect(() => {
verify();
}, []);
if (loading) return <div>Show Loading...</div>;
return status === "loggedOut" ? (
<Redirect to="/" />
) : (
<Route path={path} render={() => <>{children}</>} />
);
};
If user is logged in, render the component. If not, render login page. I notice, however, that this function is called twice. The first time, useAuthDataContext() is null. The second time, I get the correct object back.
const PrivateRoute = ({ component, ...options }) => {
const { userData } = useAuthDataContext()
console.log(userData)
const finalComponent = userData != null ? component : Login
return (
<Route {...options} component={finalComponent} />
)
};
export default PrivateRoute
I have rewritten this function as follows. Here, PrivateRoute2 is called only once, and useAuthDataContext() returns null.
const PrivateRoute2 = ({ component: Component, ...rest }) => {
const { userData } = useAuthDataContext()
console.log(userData)
return (
<Route
{...rest}
render={props =>
userData != null ? (
<Component {...props} />
) : (
<Redirect
to={{
pathname: "/login",
state: { from: props.location }
}}
/>
)
}
/>
)
}
Here is my useAuthDataContext() implementation that is causing the rerender:
export const AuthDataContext = createContext(null)
const initialAuthData = {}
const AuthDataProvider = props => {
const [authData, setAuthData] = useState(initialAuthData)
useLayoutEffect( (props) => {
const getUser = async () => {
try {
const userData = await authService.isAuthenticated()
setAuthData( {userData})
} catch (err) {
setAuthData({})
}
}
getUser()
}, [])
const onLogout = () => {
setAuthData(initialAuthData)
}
const onLogin = newAuthData => {
const userData = newAuthData
setAuthData( {userData} )
}
const authDataValue = useMemo(() => ({ ...authData, onLogin, onLogout }), [authData])
return <AuthDataContext.Provider value={authDataValue} {...props} />
}
export const useAuthDataContext = () => useContext(AuthDataContext)
export default AuthDataProvider
I think i found one solution. See this post https://hackernoon.com/whats-the-right-way-to-fetch-data-in-react-hooks-a-deep-dive-2jc13230