How to handle state in useEffect from a prop passed from infinite scroll component - javascript

I have a React component using an infinite scroll to fetch information from an api using a pageToken.
When the user hits the bottom of the page, it should fetch the next bit of information. I thought myself clever for passing the pageToken to a useEffect hook, then updating it in the hook, but this is causing all of the api calls to run up front, thus defeating the use of the infinite scroll.
I think this might be related to React's derived state, but I am at a loss about how to solve this.
here is my component that renders the dogs:
export const Drawer = ({
onClose,
}: DrawerProps) => {
const [currentPageToken, setCurrentPageToken] = useState<
string | undefined | null
>(null);
const {
error,
isLoading,
data: allDogs,
nextPageToken,
} = useDogsList({
pageToken: currentPageToken,
});
const loader = useRef(null);
// When user scrolls to the end of the drawer, fetch more dogs
const handleObserver = useCallback(
(entries) => {
const [target] = entries;
if (target.isIntersecting) {
setCurrentPageToken(nextPageToken);
}
},
[nextPageToken],
);
useEffect(() => {
const option = {
root: null,
rootMargin: '20px',
threshold: 0,
};
const observer = new IntersectionObserver(handleObserver, option);
if (loader.current) observer.observe(loader.current);
}, [handleObserver]);
return (
<Drawer
onClose={onClose}
>
<List>
{allDogs?.map((dog) => (
<Fragment key={dog?.adopterAttributes?.id}>
<ListItem className={classes.listItem}>
{dog?.adopterAttributes?.id}
</ListItem>
</Fragment>
))}
{isLoading && <div>Loading...</div>}
<div ref={loader} />
</List>
</Drawer>
);
};
useDogsList essentially looks like this with all the cruft taken out:
import { useEffect, useRef, useState } from 'react';
export const useDogsList = ({
pageToken
}: useDogsListOptions) => {
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [nextPageToken, setNextPageToken] = useState<string | null | undefined>(
null,
);
const [allDogs, setAllDogs] = useState(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const result =
await myClient.listDogs(
getDogsRequest,
{
token,
},
);
const dogListObject = result?.toObject();
const newDogs = result?.dogsList;
setNextPageToken(dogListObject?.pagination?.nextPageToken);
// if API returns a pageToken, that means there are more dogs to add to the list
if (nextPageToken) {
setAllDogs((previousDogList) => [
...(previousDogList ?? []),
...newDogs,
]);
}
}
} catch (responseError: unknown) {
if (responseError instanceof Error) {
setError(responseError);
} else {
throw responseError;
}
} finally {
setLoading(false);
}
};
fetchData();
}, [ pageToken, nextPageToken]);
return {
data: allDogs,
nextPageToken,
error,
isLoading,
};
};
Basically, the api call returns the nextPageToken, which I want to use for the next call when the user hits the intersecting point, but because nextPageToken is in the dependency array for the hook, the hook just keeps running. It retrieves all of the data until it compiles the whole list, without the user scrolling.
I'm wondering if I should be using useCallback or look more into derivedStateFromProps but I can't figure out how to make this a "controlled" component. Does anyone have any guidance here?

I suggest a small refactor of the useDogsList hook to instead return a hasNext flag and fetchNext callback.
export const useDogsList = ({ pageToken }: useDogsListOptions) => {
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [nextPageToken, setNextPageToken] = useState<string | null | undefined>(
pageToken // <-- initial token value for request
);
const [allDogs, setAllDogs] = useState([]);
// memoize fetchData callback for stable reference
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await myClient.listDogs(getDogsRequest, { token: nextPageToken });
const dogListObject = result?.toObject();
const newDogs = result?.dogsList;
setNextPageToken(dogListObject?.pagination?.nextPageToken ?? null);
setAllDogs((previousDogList) => [...previousDogList, ...newDogs]);
} catch (responseError) {
if (responseError instanceof Error) {
setError(responseError);
} else {
throw responseError;
}
} finally {
setLoading(false);
}
}, [nextPageToken]);
useEffect(() => {
fetchData();
}, []); // call once on component mount
return {
data: allDogs,
hasNext: !!nextPageToken, // true if there is a next token
error,
isLoading,
fetchNext: fetchData, // callback to fetch next "page" of data
};
};
Usage:
export const Drawer = ({ onClose }: DrawerProps) => {
const { error, isLoading, data: allDogs, hasNext, fetchNext } = useDogsList({
pageToken // <-- pass initial page token
});
const loader = useRef(null);
// When user scrolls to the end of the drawer, fetch more dogs
const handleObserver = useCallback(
(entries) => {
const [target] = entries;
if (target.isIntersecting && hasNext) {
fetchNext(); // <-- Only fetch next if there is more to fetch
}
},
[hasNext, fetchNext]
);
useEffect(() => {
const option = {
root: null,
rootMargin: "20px",
threshold: 0
};
const observer = new IntersectionObserver(handleObserver, option);
if (loader.current) observer.observe(loader.current);
// From #stonerose036
// clear previous observer in returned useEffect cleanup function
return observer.disconnect;
}, [handleObserver]);
return (
<Drawer onClose={onClose}>
<List>
{allDogs?.map((dog) => (
<Fragment key={dog?.adopterAttributes?.id}>
<ListItem className={classes.listItem}>
{dog?.adopterAttributes?.id}
</ListItem>
</Fragment>
))}
{isLoading && <div>Loading...</div>}
<div ref={loader} />
</List>
</Drawer>
);
};
Disclaimer
Code hasn't been tested, but IMHO it should be pretty close to what you are after. There may be some minor tweaks necessary to satisfy any TSLinting issues, and getting the correct initial page token to the hook.

While Drew and #debuchet's answers helped me improve the code, the problem around multiple renders ended up being solved by tackling the observer itself. I had to disconnect it afterwards
useEffect(() => {
const option = {
root: null,
rootMargin: '20px',
threshold: 0,
};
const observer = new IntersectionObserver(handleObserver, option);
if (loader.current) observer.observe(loader.current);
return () => {
observer.disconnect();
};
}, [handleObserver]);

Related

React Native - How to memoize a component correctly?

This is my parent component:
const A = () => {
const [data, setData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const isFetching = useRef(false);
...
const fetchMoreData = async () => {
if(isFetching.current) return;
isFetching.current = true;
setIsLoading(true);
try {
const newData = await ...
setData([...data, ...newData]);
}
catch(err) {
...
}
setIsLoading(false);
isFetching.current = false;
}
...
return <B data={data} isLoading={isLoading} onEndReached={fetchMoreData} />
}
And I am trying to memoize my child component B, to avoid unnecessary re-renders. Currently, I am doing the following:
const B = memo(({ data, isLoading, onEndReached }) => {
...
return (
<FlatList
data={data}
isLoading={isLoading}
onEndReached={onEndReached}
/>
);
},
(prevProps, nextProps) => {
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data) &&
prevProps.isLoading === nextProps.isLoading;
});
But, I think, that my memoization can cause problems... as I am not adding prevProps.onEndReached === nextProps.onEndReached
But... all works fine :/ Btw I suppose that there can be a little chance to see unexpected things happening because of not adding it. What do you think? Is it necessary to add methods in the areEqual method?

How to call a React Hook from a child component to refresh results

I'm trying to refresh the results on the page, but the refresh button is in a child component to where my React Hook is originally called.
export const ParentComponent = ({
}) => {
const infoINeed = useSelector(getInfoINeed);
const { error, isLoading, data } = useMyAwesomeHook(infoINeed.name);
return (
<div>
<Header/>
<Body className={classes.body}>
<div>Hello Stack overflow</div>
<Body>
</div>
);
};
My Awesome hook looks like this
export const useDogCounts = (name: string | undefined) => {
const { data: token, error: authError } = useAuthHook();
const [error, setError] = useState<Error | null>(null);
const [isLoading, setLoading] = useState(true);
useEffect(() => {
const fetchMyData = async () => {
const request = myRequest(name);
try {
setLoading(true);
const counts = callMyFunction()
setMyData(counts);
setLoading(false);
} catch (requestError) {
if (requestError === null) {
setError(requestError);
} else {
throw requestError;
}
setLoading(false);
}
};
fetchMyData();
}, [name, token]);
return {
data: dogCounts,
error,
isLoading,
};
};
then in my <Header/> component, I have a refresh button that I want to call the hook.
import React, { FC } from 'react';
import { Button } from '#material-ui/core';
export const Header: FC<HeaderProps> = ({}) => {
return (
<Page.Header className={classes.headerWrapper}>
<Page.Title>Dog Counts</Page.Title>
<Button
onClick={() => {}} // functionality to go here
>
Refresh
</Button>
</Header>
);
};
I've tried a couple approaches, including passing a variable into the useDogCount hook called refresh, which the Header component changes in the state in order to trigger the useEffect hook in my main hook. It seemed a bit messy to do it this way and introduce a new variable to keep track of.
I also have implemented something like this elsewhere a different time where I did not use a useEffect hook inside my custom hook, and instead passed the Promise back to the required place to refresh it. However, I need the useEffect hook here to check for updating name or token.
You can return the function used to fetch the data from your custom hook :
export const ParentComponent = () => {
const infoINeed = useSelector(getInfoINeed);
const { error, isLoading, data, fetchData } = useMyAwesomeHook(infoINeed.name);
return (
<div>
<Header onClickRefresh={fetchData}/>
<Body className={classes.body}>
<div>Hello Stack overflow</div>
<Body>
</div>
);
};
export const useDogCounts = (name: string | undefined) => {
const { data: token, error: authError } = useAuthHook();
const [error, setError] = useState<Error | null>(null);
const [isLoading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
... // code to fetch the data
}, [name, token]);
useEffect(fetchData, [fetchData]);
return {
data: dogCounts,
fetchData,
error,
isLoading,
};
};
export const Header: FC<HeaderProps> = ({onClickRefresh}) => {
return (
<Page.Header className={classes.headerWrapper}>
<Page.Title>Dog Counts</Page.Title>
<Button onClick={onClickRefresh}>
Refresh
</Button>
</Header>
);
};
Right now there is no connection between your hook and either of components in terms of firing the request for the data. What I would suggest is to add a function to your hook that is going to call your api and return that function from the hook
export const useDogCounts = (name: string | undefined) => {
const { data: token, error: authError } = useAuthHook();
const [error, setError] = useState<Error | null>(null);
const [isLoading, setLoading] = useState(true);
const callAnApi = async () => {
// ... body of the useEffect
}
useEffect(() => {
const fetchMyData = async () => {
const request = myRequest(name);
try {
setLoading(true);
const counts = callMyFunction()
setMyData(counts);
setLoading(false);
} catch (requestError) {
if (requestError === null) {
setError(requestError);
} else {
throw requestError;
}
setLoading(false);
}
};
fetchMyData();
}, [name, token]);
return {
data: dogCounts,
error,
isLoading,
};
};
then in your ParentComponent you can destructure it as
const { error, isLoading, data, callAnApi } = useMyAwesomeHook(infoINeed.name);
and pass it to Header component as prop where you just use it as
<Button
onClick={callAnApiHandler}
>
Refresh
</Button>
Then you could call this new function inside your useEffect for further refactor

Translating context to redux with setTimeout

I have this context:
interface AlertContextProps {
show: (message: string, duration: number) => void;
}
export const AlertContext = createContext<AlertContextProps>({
show: (message: string, duration: number) => {
return;
},
});
export const AlertProvider: FC<IProps> = ({ children }: IProps) => {
const [alerts, setAlerts] = useState<JSX.Element[]>([]);
const show = (message: string, duration = 6000) => {
let alertKey = Math.random() * 100000;
setAlerts([...alerts, <Alert message={message} duration={duration} color={''} key={alertKey} />]);
setTimeout(() => {
setAlerts(alerts.filter((i) => i.key !== alertKey));
}, duration + 2000);
};
return (
<>
{alerts}
<AlertContext.Provider value={{ show }}>{children}</AlertContext.Provider>
</>
);
};
which I need to "translate" into a redux slice. I got a hang of everything, apart from the show method. What would be the correct way to treat it? I was thinking about a thunk, but it's not really a thunk. Making it a reducer with setTimeout also seems like an ugly thing to do. So how would you guys do it?
My code so far:
type Alert = [];
const initialState: Alert = [];
export const alertSlice = createSlice({
name: 'alert',
initialState,
reducers: {
setAlertState(state, { payload }: PayloadAction<Alert>) {
return payload;
},
},
});
export const { setAlertState } = alertSlice.actions;
export const alertReducer = alertSlice.reducer;
The timeout is a side effect so you could implement that in a thunk.
You have an action that shows an alert message that has a payload of message, id and time to display, when that time runs out then the alert message needs to be removed so you need a remove alert message action as well that is dispatched from the thunk with a payload of the id of the alert message.
I am not sure why you add 2 seconds to the time to hide the message duration + 2000 since the caller can decide how long the message should show I don't think it should half ignore that value and randomly add 2 seconds.
Here is a redux example of the alert message:
const { Provider, useDispatch, useSelector } = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;
const initialState = {
messages: [],
};
//action types
const ADD_MESSAGE = 'ADD_MESSAGE';
const REMOVE_MESSAGE = 'REMOVE_MESSAGE';
//action creators
const addMessage = (id, text, time = 2000) => ({
type: ADD_MESSAGE,
payload: { id, text, time },
});
const removeMessage = (id) => ({
type: REMOVE_MESSAGE,
payload: id,
});
//id generating function
const getId = (
(id) => () =>
id++
)(1);
const addMessageThunk = (message, time) => (dispatch) => {
const id = getId();
dispatch(addMessage(id, message, time));
setTimeout(() => dispatch(removeMessage(id)), time);
};
const reducer = (state, { type, payload }) => {
if (type === ADD_MESSAGE) {
return {
...state,
messages: state.messages.concat(payload),
};
}
if (type === REMOVE_MESSAGE) {
return {
...state,
messages: state.messages.filter(
({ id }) => id !== payload
),
};
}
return state;
};
//selectors
const selectMessages = (state) => state.messages;
//creating store with redux dev tools
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
reducer,
initialState,
composeEnhancers(
applyMiddleware(
//simple implementation of thunk (not official redux-thunk)
({ dispatch }) =>
(next) =>
(action) =>
typeof action === 'function'
? action(dispatch)
: next(action)
)
)
);
const App = () => {
const messages = useSelector(selectMessages);
const dispatch = useDispatch();
return (
<div>
<button
onClick={() =>
dispatch(addMessageThunk('hello world', 1000))
}
>
Add message
</button>
<ul>
{messages.map((message) => (
<li key={message.id}>{message.text}</li>
))}
</ul>
</div>
);
};
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<div id="root"></div>
#HMR's use of a thunk is fine, but I don't like what they've done to your reducer. You're already using redux-toolkit which is great! redux-toolkit actually includes and exports a nanoid function which they use behind the scenes to create unique ids for thunks. You can use that instead of Math.random() * 100000.
I always start by thinking about types. What is an Alert? You don't want to store the <Alert/> because a JSX.Element is not serializable. Instead you should just store the props. You'll definitely store the message and key/id. If you handle expiration on the front-end then you would also store the duration, but if the expiration is handled by a thunk then I don't think you need it in the redux state or component props.
It seems like you want to allow multiple alerts at one time, so return payload is not going to cut it for your reducer. You'll need to store an array or a keyed object will all of your active alerts.
You absolute should not use setTimeout in a reducer because that is a side effect. You can use it either in a thunk or in a useEffect in the Alert component. My inclination is towards the component because it seems like the alert should probably be dismissible as well? So you can use the same function for handling dismiss clicks and automated timeouts.
We can define the info that we want to store for each alert.
type AlertData = {
message: string;
id: string;
duration: number;
}
And the info that we need to create that alert, which is the same but without the id because we will generate the id in the reducer.
type AlertPayload = Omit<AlertData, 'id'>
Our state can be an array of alerts:
const initialState: AlertData[] = [];
We need actions to add a new alert and to remove an alert once it has expired.
import { createSlice, PayloadAction, nanoid } from "#reduxjs/toolkit";
...
export const alertSlice = createSlice({
name: "alert",
initialState,
reducers: {
addAlert: (state, { payload }: PayloadAction<AlertPayload>) => {
const id = nanoid(); // create unique id
state.push({ ...payload, id }); // add to the state
},
removeAlert: (state, { payload }: PayloadAction<string>) => {
// filter the array -- payload is the id
return state.filter((alert) => alert.id !== payload);
}
}
});
export const { addAlert, removeAlert } = alertSlice.actions;
export const alertReducer = alertSlice.reducer;
So now to the components. What I have in mind is that you would use a selector to select all of the alerts and then each alert will be responsible for its own expiration.
export const AlertComponent = ({ message, duration, id }: AlertData) => {
const dispatch = useDispatch();
// function called when dismissed, either by click or by timeout
// useCallback is just so this can be a useEffect dependency and won't get recreated
const remove = useCallback(() => {
dispatch(removeAlert(id));
}, [dispatch, id]);
// automatically expire after the duration, or if this component unmounts
useEffect(() => {
setTimeout(remove, duration);
return remove;
}, [remove, duration]);
return (
<Alert
onClose={remove} // can call remove directly by clicking the X
dismissible
>
<Alert.Heading>Alert!</Alert.Heading>
<p>{message}</p>
</Alert>
);
};
export const ActiveAlerts = () => {
const alerts = useSelector((state) => state.alerts);
return (
<>
{alerts.map((props) => (
<AlertComponent {...props} key={props.id} />
))}
</>
);
};
I also made a component to create alerts to test this out and make sure that it works!
export const AlertCreator = () => {
const dispatch = useDispatch();
const [message, setMessage] = useState("");
const [duration, setDuration] = useState(8000);
return (
<div>
<h1>Create Alert</h1>
<label>
Message
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</label>
<label>
Duration
<input
type="number"
step="1000"
value={duration}
onChange={(e) => setDuration(parseInt(e.target.value, 10))}
/>
</label>
<button
onClick={() => {
dispatch(addAlert({ message, duration }));
setMessage("");
}}
>
Create
</button>
</div>
);
};
const App = () => (
<div>
<AlertCreator />
<ActiveAlerts />
</div>
);
export default App;
Code Sandbox Link

Display no results component when there are no results

I'm trying to show the no results components whenever the api has finished loading and when no results are returned. The issue I'm having, is I am seeing the no results components displayed first for a few seconds and then the results showing whenever the api returns data. I should never want to see the no results component showing if there are results returned from the api.
import React, { useEffect, useState } from 'react';
import NoResults from './NoResults';
const Users = () => {
const [results, setResults] = useState([]);
const [isResultsLoading, setIsResultsLoading] = useState(true);
const isLoading = () => {
if (isResultsLoading) return <ResultsLoader />;
if (results && results.length > 0)
return (
<UserTableWrapper>
<UserTable
data={results}
/>
</UserTableWrapper>
);
return <NoResults heading="No users available." />;
};
useEffect(() => {
let isMounted = true;
const getData = async () => {
if (isMounted) {
const users = await fetchUsers(); // is just an api call
if (users && users.length > 0) return { users, loading: false };
return { users: null, loading: true };
}
return { users: null, loading: true };
};
getData().then(({ users, loading }) => {
if (isMounted) {
if (users) setResults(users);
setIsResultsLoading(loading);
}
});
return () => {
isMounted = false;
};
}, []);
return (
<>
<h1>Users</h1>
{isLoading()}
</>
);
};
};
export default Users;
Check for the length of results with results.length since results already exists as an empty array.
When you get your data and parse it simply set both the states for results with the data, and isLoading to false.
Perhaps rename the isLoading function to something more representative of what the function does. I've called mine getJSX.
Here's a working example that uses a mock API. (Note I've had to use this without async and await because snippets haven't caught up with the latest Babel version.) You can change the JSON that's returned by the API by uncommenting/commenting out the JSON statements in the first couple of lines.
const {useState, useEffect} = React;
const json= '[1, 2, 3, 4]';
// const json = '[]';
function mockApi() {
return new Promise((res, rej) => {
setTimeout(() => res(json), 3000);
});
}
function Example() {
const [results, setResults] = useState([]);
const [isResultsLoading, setIsResultsLoading] = useState(true);
function getJSX() {
if (isResultsLoading) return <div>Loading</div>;
if (results.length) {
return results.map(el => <div>{el}</div>);
}
return <div>No users available.</div>;
};
useEffect(() => {
mockApi()
.then(res => JSON.parse(res))
.then(data => {
setResults(data);
setIsResultsLoading(false);
});
}, []);
return <div>{getJSX()}</div>
};
ReactDOM.render(
<Example />,
document.getElementById("react")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>
You should rely on conditional rendering and simplify your logic a little bit.
import React, { useEffect, useState } from "react";
import NoResults from "./NoResults";
const Users = () => {
// This holds the results - default to null till we get a successful API response
const [results, setResults] = useState(null);
// This should be a boolean stating if the API call is pending
const [isResultsLoading, setIsResultsLoading] = useState(true);
useEffect(() => {
const getData = async () => {
const users = await fetchUsers(); // is just an api call
if (users && users.length > 0) {
// if the result is good, store it
setResults(users);
}
// By the way, the api call is finished now
setIsResultsLoading(false);
};
getData();
}, []); // no deps => this effect will run just once, when the component mounts
if (isResultsLoading) {
// Render nothing while API call is pending
return null;
} else {
if (results) {
// The API has returned a good result, so render it!
return (
<UserTableWrapper>
<UserTable data={results} />
</UserTableWrapper>
);
} else {
// No good result, render the fallback component
return <NoResults heading="No users available." />;
}
}
};
export default Users;
You've a lot of extraneous conditionals and code duplication (not as DRY as it could be). Try cutting down on the user checks before you've even updated state, and you likely don't need the mounted check. Conditionally render the UI in the return.
const Users = () => {
const [results, setResults] = useState([]);
const [isResultsLoading, setIsResultsLoading] = useState(true);
useEffect(() => {
fetchUsers() // is just an api call
.then(users => {
setResults(users);
})
.catch(error => {
// handle any errors, etc...
})
.finally(() => {
setIsResultsLoading(false); // <-- clear loading state regardless of success/failure
});
}, []);
return (
<>
<h1>Users</h1>
{isResultsLoading ? (
<ResultsLoader />
) : results.length ? ( // <-- any non-zero length is truthy
<UserTableWrapper>
<UserTable data={results} />
</UserTableWrapper>
) : (
<NoResults heading="No users available." />
)}
</>
);
};

Edit data when using a custom data fetching React Hook?

I've been trying to make a chart with data fetched from an API that returns data as follows:
{
"totalAmount": 230,
"reportDate": "2020-03-05"
},
{
"totalAmount": 310,
"reportDate": "2020-03-06"
}
...
The date string is too long when displayed as a chart, so I want to shorten it by removing the year part.
2020-03-06 becomes 03/06
Following a great tutorial by Robin Wieruch, I now have a custom Hook to fetch data:
const useDataApi = (initialUrl, initialData) => {
const [data, setData] = useState(initialData);
const [url, setUrl] = useState(initialUrl);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const response = await fetch(url);
const data = await response.json();
setData(data);
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [url]);
return [{ data, isLoading, isError }];
};
Along with my charting component written in React Hooks:
const MyChart = () => {
const [{ data, isLoading, isError }] = useDataApi(
"https://some/api/domain",
[]
);
useEffect(() => {
// I'm using useEffect to replace every date strings before rendering
if (data) {
data.forEach(
d =>
(d.reportDate = d.reportDate
.replace(/-/g, "/")
.replace(/^\d{4}\//g, ""))
);
}
}, [data]);
return (
<>
<h1>My Chart</h1>
{isError && <div>Something went wrong</div>}
{isLoading ? (
. . .
) : (
<>
. . .
<div className="line-chart">
<MyLineChart data={data} x="reportDate" y="totalAmount" />
</div>
</>
)}
</>
);
};
The above works. But I have a feeling that this might not be the best practice because useEffect would be called twice during rendering. And when I try to adopt useReducer in my custom Hook, the code does not work anymore.
So I'm wondering what is the best way to edit data in this circumstance?
You could create a mapping function for your data, which is then used by the hook. This can be done outside of your hook function.
const mapChartDataItem = (dataItem) => ({
...dataItem,
reportDate: dataItem.reportDate.replace(/-/g, "/").replace(/^\d{4}\//g, ""))
});
The reportDate mapping is the same code as you have used in your useEffect.
Then in your hook function:
const data = await response.json();
// this is the new code
const mappedData = data.map(mapChartDataItem);
// change setData to use the new mapped data
setData(mappedData);
Doing it here means that you're only mapping your objects once (when they are fetched) rather than every time the value of data changes.
Update - with injecting the function to the hook
Your hook will now look like this:
const useDataApi = (initialUrl, initialData, transformFn) => {
const [data, setData] = useState(initialData);
const [url, setUrl] = useState(initialUrl);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const response = await fetch(url);
const data = await response.json();
// if transformFn isn't provided, then just set the data as-is
setData((transformFn && transformFn(data)) || data);
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [url, transformFn]);
return [{ data, isLoading, isError }];
};
Then to call it, you can use the following:
const mapChartDataItem = (dataItem) => ({
...dataItem,
reportDate: dataItem.reportDate.replace(/-/g, "/").replace(/^\d{4}\//g, ""))
});
const transformFn = useCallback(data => data.map(mapChartDataItem), []);
const [{ data, isLoading, isError }] = useDataApi(
"https://some/api/domain",
[],
transformFn
);
For simplicity, because the transformFn argument is the last parameter to the function, then you can choose to call your hook without it, and it will just return the data as it was returned from the fetch call.
const [{ data, isLoading, isError }] = useDataApi(
"https://some/api/domain",
[]
);
would work in the same was as it previously did.
Also, if you don't want (transformFn && transformFn(data)) || data in your code, you could give the transformFn a default value in your hook, more along the lines of:
const useDataApi = (
initialUrl,
initialData,
transformFn = data => data) => {
// then the rest of the hook in here
// and call setData with the transformFn
setData(transformFn(data));
}

Categories

Resources