How to update the state with the latest fetched item with an interval callback function inside useEffect? - reactjs

I'm quite new to the React-TS world and I have recently been playing with useState and useEffect hooks only basically.
I have the following functional component inside which I'd like to fetch N items the first time and then start a periodic function that fetches the last item from the response data, updating the current state.
const fetcher = async (url: string) => await axios.get(url).then((res: AxiosResponse) => res.data);
type AirflowData = {
value: number; // perc values from 0 to 1
timestamp: number; // UTC time
};
const ActionDetector: React.FC = () => {
const [alerts, setAlerts] = useState<AirflowData[]>([]);
useEffect(() => {
// Fetch the latest N alerts first
getAlerts(100);
// Then start fetching the last alert every N milliseconds
const interval = setInterval(() => getLatestAlert(), 1000);
// Clear interval
return () => {
clearInterval(interval);
};
}, []);
/**
* Return the alert data after fetching it.
* #param numAlerts number of the last N alerts to return
*/
const getAlerts = async (numAlerts: number) => {
const fetchedAlerts: AirflowData[] = await fetcher("http://localhost:9500/alerts");
setAlerts(fetchedAlerts.slice(-numAlerts));
};
/**
* Return the latest alert data available.
*/
const getLatestAlert = async () => {
const fetchedAlerts: AirflowData[] = await fetcher("http://localhost:9500/alerts");
const latestFetchedAlert = fetchedAlerts.slice(-1)[0];
const latestAlert = alerts.slice(-1)[0];
if (latestFetchedAlert && latestAlert && latestFetchedAlert.timestamp !== latestAlert.timestamp) {
// Append the alert only if different from the previous one
setAlerts([...alerts, latestFetchedAlert]);
}
};
console.log(alerts);
return <></>
}
export default ActionDetector
The problem with this approach is that latestAlert is always undefined and that is due, if I understood how React works under the hood correctly, to the initial state change re-rendering trigger. After getAlerts() is called and fires setAlerts(...), the component starts the re-rendering and so, since getLatestAlert() is called inside the useEffect only the first time (the first render), it always read alerts as the initialized empty array.
I don't know if this is the correct reason behind this, but how can I achieve what I'm trying to do the right way?

The fundamental issue is that when updating state based on existing state, you need to be sure you have the latest state information. Your getLatestAlerts function closes over the alerts constant that was in scope when it was created, so it only ever uses that version of the constant (not the updated one from a subsequent render). Your useEffect setInterval callback closes over the getLatestAlerts function that was in scope when it was created, and only ever uses that version of the function.
To be sure you have the latest state, use the callback version of the state setter instead of the constant:
const getLatestAlert = async () => {
const fetchedAlerts: AirflowData[] = await fetcher("http://localhost:9500/alerts");
const latestFetchedAlert = fetchedAlerts.slice(-1)[0];
if (latestFetchedAlert) {
setAlerts(alerts => {
const latestAlert = alerts.slice(-1)[0];
if (latestFetchedAlert && latestAlert && latestFetchedAlert.timestamp !== latestAlert.timestamp) {
// Append the alert only if different from the previous one
alerts = [...alerts, latestFetchedAlert];
}
return alerts;
});
}
};
Purely as a side note, I wouldn't use the idiom you seem to be using to get the last item from an array, array.slice(-1)[0]. Instead, I'd either use array[array.length - 1], or use the at method which just achieved Stage 4 and will be in this year's spec (it's easily polyfilled for older environments).

Related

How to re-render a component when a non state object is updated

I have an object which value updates and i would like to know if there is a way to re-render the component when my object value is updated.
I can't create a state object because the state won't be updated whenever the object is.
Using a ref is not a good idea(i think) since it does not cause a re-render when updated.
The said object is an instance of https://docs.kuzzle.io/sdk/js/7/core-classes/observer/introduction/
The observer class doesn't seem to play well with your use case since it's just sugar syntax to manage the updates with mutable objects. The documentation already has a section for React, and I suggest following that approach instead and using the SDK directly to retrieve the document by observing it.
You can implement this hook-observer pattern
import React, { useCallback, useEffect, useState } from "react";
import kuzzle from "./services/kuzzle";
const YourComponent = () => {
const [doc, setDoc] = useState({});
const initialize = useCallback(async () => {
await kuzzle.connect();
await kuzzle.realtime.subscribe(
"index",
"collection",
{ ids: ["document-id"] },
(notification) => {
if (notification.type !== "document" && notification.event !== "write")
return;
// getDocFromNotification will have logic to retrieve the doc from response
setDoc(getDocFromNotification(notification));
}
);
}, []);
useEffect(() => {
initialize();
return () => {
// clean up
if (kuzzle.connected) kuzzle.disconnect();
};
}, []);
return <div>{JSON.stringify(doc)}</div>;
};
useSyncExternalStore, a new React library hook, is what I believe to be the best choice.
StackBlitz TypeScript example
In your case, a simple store for "non state object" is made:
function createStore(initialState) {
const callbacks = new Set();
let state = initialState;
// subscribe
const subscribe = (cb) => {
callbacks.add(cb);
return () => callbacks.delete(cb);
};
// getSnapshot
const getSnapshot = () => state;
// setState
const setState = (fn) => {
state = fn(state);
callbacks.forEach((cb) => cb());
};
return { subscribe, getSnapshot, setState };
}
const store = createStore(initialPostData);
useSyncExternalStore handles the job when the update of "non state object" is performed:
const title = React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().title
);
In the example updatePostDataStore function get fake json data from JSONPlaceholder:
async function updatePostDataStore(store) {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${Math.floor(Math.random()*100)+1}`)
const postData = await response.json()
store.setState((prev)=>({...prev,...postData}));
};
My answer assumes that the object cannot for some reason be in React as state (too big, too slow, too whatever). In most cases that's probably a wrong assumption, but it can happen.
I can't create a state object because the state won't be updated whenever the object is
I assume you mean you can't put that object in a React state. We could however put something else in state whenever we want an update. It's the easiest way to trigger a render in React.
Write a function instead of accessing the object directly. That way you can intercept every call that modifies the object. If you can reliably run an observer function when the object changes, that would work too.
Whatever you do, you can't get around calling a function that does something like useState to trigger a render. And you'll have to call it in some way every time you're modifying the object.
const myObject = {};
let i = 0;
let updater = null;
function setMyObject(key, value) {
myObject[key] = value;
i++;
if (updater !== null) {
updater(i);
}
};
Change your code to access the object only with setMyObject(key, value).
You could then put that in a hook. For simplicity I'll assume there's just 1 such object ever on the page.
function useCustomUpdater() {
const [, setState] = useState(0);
useEffect(()=>{
updater = setState;
return () => {
updater = null;
}
}, [setState]);
}
function MyComponent() {
useCustomUpdater();
return <div>I re-render when that object changes</div>;
}
Similarly, as long as you have control over the code that interacts with this object, you could wrap every such call with a function that also schedules an update.
Then, as long as your code properly calls the function, your component will get re-rendered. The only additional state is a single integer.
The question currently lacks too much detail to give a good assessment whether my suggested approach makes sense. But it seems like a very simple way to achieve what you describe.
It would be interesting to get more information about what kind of object it is, how frequently it's updated, and in which scope it lives.

How to use useEffect/state/variables properly without user interaction?

My goal is to set up a game loop but a simple test isn't working as expected. In the following component, I am trying the useEffect hook to increment food. I expect to see "Food: 1". Instead I see "Food: 0". When I inspect the component with the dev tools, I can see that food is 2. I've discovered that the component mounts, increments food, unmounts, mounts again and increments food once more.
I have two questions:
Can I do something about the double mount? (like prevent it or wait until the final mount with a nested component perhaps?)
Why does the displayed food count still equal zero? Is it because game inside <span>Food: {game.food}</span> still refers to the initial instance? If so, how do I get the latest instance?
Component:
import React from "react";
class Game {
food = 0;
}
export default function App() {
const [game, setGame] = React.useState(new Game());
React.useEffect(() => {
setGame((game) => {
game.food += 1;
return game;
});
});
return <span>Food: {game.food}</span>;
}
Don't Mutate State Objects
React uses reference comparisons and expects the reference of the root state object to change if any data within it has changed.
For Example:
// DON'T
setGame((game) => {
// mutate and return same object
game.food += 1;
return game;
});
// DO
setGame((current) => {
// create new object with updated food value
return {
...current,
food: current.food + 1
};
});
Using the same reference will cause components to not update as expected.
useEffect Dependency Array
A useEffect without a dependency array will trigger every time the component renders.
If you wish for the useEffect to only trigger on mount provide an empty dependency array.
For Example:
// Every Render
useEffect(() => {
alert('I trigger every render');
});
// On Mount
useEffect(() => {
alert('I trigger on mount');
}, []);
// Everytime the reference for game changes
useEffect(() => {
alert('I trigger everytime the game state is update');
}, [game]);
Conclusion
"Mount twice" probably you are using react 18 and have strict mode enabled. It will trigger useEffect twice in dev mode from docs
If you want to update the view, you should make the reference of the game variable changes (instead of changing its attrs).
Solution
const initialGame = {
food: 0
}
export default function App() {
const [game, setGame] = React.useState(initialGame);
React.useEffect(() => {
setGame((game) => {
game.food += 1;
return {...game};
});
}, []);
return <span>Food: {game.food}</span>;
}
No you should not useEffect as a loop, its execution depends on your component states and its parent component, so this leaves 3 solutions 1st while loop, 2nd requestAnimationFrame and 3rd setInterval. while loop is discouraged because it will block event loop and canceling/stopping can be tedious.
double mount ? i think its react double checking function, which does this only dev mode. Once you switch to requestAnimationFrame you won't be having that issue.
use tried mutate state and react doesn't recognizes this so it doesn't re render. solution: return new object.
updating states
useEffect(() => {
setGame((current) => {
const newState = { ...current, food: current.food + 1 }
return newState
})
}, [])
using setInterval to act as loop
useEffect(() => {
const id = setInterval(() => setCount((count) => count + 1), 1000)
return () => clearInterval(id)
}, [])
using requestAnimationFrame to act as loop
// credit: https://css-tricks.com/using-requestanimationframe-with-react-hooks/
const requestRef = React.useRef()
const animate = (time) => {
setCount((count) => count + 1)
requestRef.current = requestAnimationFrame(animate)
}
useEffect(() => {
requestRef.current = requestAnimationFrame(animate)
return () => cancelAnimationFrame(requestRef.current)
}, []) // Make sure the effect runs only once

React keep old state - new state not updated

In a React project, I have a state gameResults with a array of games, and I have a function to get the list of games based on a query :
useEffect(() => {
const timeoutId = setTimeout(() => {
if (gameQuery.length > 0) {
axios.get(`/api/games/${gameQuery}`).then((response) => {
const igdbGames: IGDBGame[] = response.data.games;
const formatedGames = formatGames(igdbGames);
setGameResults(formatedGames);
});
}
}, 300);
return () => clearTimeout(timeoutId);
}, [gameQuery]);
For each game, I don't have the cover, so I get the cover for each game :
const loadGamesImages = async () => {
for (let i = 0; i < gameResults.length; i++) {
axios
.get(`/api/cover/${gameResults[i].id}`)
.then((response) => {
const coverUrl: IGDBCover = response.data.covers[0];
const newGame = {
...gameResults[i],
cover: coverUrl.url.replace("//", "https://"),
};
const newGames = gameResults.filter(
(game: Game) => game.id !== newGame.id
);
setGameResults([...newGames, newGame]);
})
.catch((error) => {
console.log("error", error);
});
await sleep(300);
}
console.log("finish");
};
useEffect(() => {
loadGamesImages();
}, [gameResults.length]);
Here is my problem : when React update the state, the old state is not there anymore. I explain : for the first cover, it's ok the new state has the first game covered. But when he make a new state for the second game, as you can see i get the gameResults state, but in this one the first game has no cover anymore.
Here is the result :
What have I done wrong ?
Each one of your looped asynchronous calls closes over the initial binding of the stateful gameResults - and gameResults starts out empty. For example, with the first Promise that resolves, these line:
const newGames = gameResults.filter(
(game: Game) => game.id !== newGame.id
);
setGameResults([...newGames, newGame]);
have the gameResults refer to the empty array, so setGameResults properly spreads the empty array plus the just-added newGame.
But then on further Promise resolutions, they also close over the initially-empty gameResults - all the async calls happened before the component re-rendered.
Use a callback instead, so that the async calls don't overwrite each other:
setGameResults((gameResults) => {
const newGames = gameResults.filter(
(game) => game.id !== newGame.id
);
return [...newGames, newGame];
});
(also note that there's no need to explicitly note the type of a parameter that TS can already infer automatically: (game: Game) can be just game)
Once this is working, I'd also suggest tweaking your code so that, when the effect hook runs again, only covers that have not been retrieved yet get requested again. This'll save you from unnecessarily making duplicate requests.

How to wait for multiple state updates in multiple hooks?

Example
In my scenario I have a sidebar with filters.. each filter is created by a hook:
const filters = {
customerNoFilter: useFilterForMultiCreatable(),
dateOfOrderFilter: useFilterForDate(),
requestedDevliveryDateFilter: useFilterForDate(),
deliveryCountryFilter: useFilterForCodeStable()
//.... these custom hooks are reused for like 10 more filters
}
Among other things the custom hooks return currently selected values, a reset() and handlers like onChange, onRemove. (So it's not just a simple useState hidden behind the custom hooks, just keep that in mind)
Basically the reset() functions looks like this:
I also implemented a function to clear all filters which is calling the reset() function for each filter:
const clearFilters = () => {
const filterValues = Object.values(filters);
for (const filter of filterValues) {
filter.reset();
}
};
The reset() function is triggering a state update (which is of course async) in each filter to reset all the selected filters.
// setSelected is the setter comming from the return value of a useState statement
const reset = () => setSelected(initialSelected);
Right after the resetting I want to do stuff with the reseted/updated values and NOT with the values before the state update, e.g. calling API with reseted filters:
clearFilters();
callAPI();
In this case the API is called with the old values (before the update in the reset())
So how can i wait for all filters to finish there state updated? Is my code just badly structured? Am i overseeing something?
For single state updates I could simply use useEffect but this would be really cumbersome when waiting for multiple state updates..
Please don't take the example to serious as I face this issue quite often in quite different scenarios..
So I came up with a solution by implementing a custom hook named useStateWithPromise:
import { SetStateAction, useEffect, useRef, useState } from "react";
export const useStateWithPromise = <T>(initialState: T):
[T, (stateAction: SetStateAction<T>) => Promise<T>] => {
const [state, setState] = useState(initialState);
const readyPromiseResolverRef = useRef<((currentState: T) => void) | null>(
null
);
useEffect(() => {
if (readyPromiseResolverRef.current) {
readyPromiseResolverRef.current(state);
readyPromiseResolverRef.current = null;
}
/**
* The ref dependency here is mandatory! Why?
* Because the useEffect would never be called if the new state value
* would be the same as the current one, thus the promise would never be resolved
*/
}, [readyPromiseResolverRef.current, state]);
const handleSetState = (stateAction: SetStateAction<T>) => {
setState(stateAction);
return new Promise(resolve => {
readyPromiseResolverRef.current = resolve;
}) as Promise<T>;
};
return [state, handleSetState];
};
This hook will allow to await state updates:
const [selected, setSelected] = useStateWithPromise<MyFilterType>();
// setSelected will now return a promise
const reset = () => setSelected(undefined);
const clearFilters = () => {
const promises = Object.values(filters).map(
filter => filter.reset()
);
return Promise.all(promises);
};
await clearFilters();
callAPI();
Yey, I can wait on state updates! Unfortunatly that's not all if callAPI() is relying on updated state values ..
const [filtersToApply, setFiltersToApply] = useState(/* ... */);
//...
const callAPI = () => {
// filtersToApply will still contain old state here, although clearFilters() was "awaited"
endpoint.getItems(filtersToApply);
}
This happens because the executed callAPI function after await clearFilters(); is is not rerendered thus it points to old state. But there is a trick which requires an additional useRef to force rerender after filters were cleared:
useEffect(() => {
if (filtersCleared) {
callAPI();
setFiltersCleared(false);
}
// eslint-disable-next-line
}, [filtersCleared]);
//...
const handleClearFiltersClick = async () => {
await orderFiltersContext.clearFilters();
setFiltersCleared(true);
};
This will ensure that callAPI was rerendered before it is executed.
That's it! IMHO a bit messy but it works.
If you want to read a bit more about this topic, feel free to checkout my blog post.

react hooks and setInterval

Is there any alternative to just keeping a "clock" in the background to implement auto-next (after a few seconds) in carousel using react hooks?
The custom react hook below implements a state for a carousel that supports manual (next, prev, reset) and automatic (start, stop) methods for changing the carousel's current (active) index.
const useCarousel = (items = []) => {
const [current, setCurrent] = useState(
items && items.length > 0 ? 0 : undefined
);
const [auto, setAuto] = useState(false);
const next = () => setCurrent((current + 1) % items.length);
const prev = () => setCurrent(current ? current - 1 : items.length - 1);
const reset = () => setCurrent(0);
const start = _ => setAuto(true);
const stop = _ => setAuto(false);
useEffect(() => {
const interval = setInterval(_ => {
if (auto) {
next();
} else {
// do nothing
}
}, 3000);
return _ => clearInterval(interval);
});
return {
current,
next,
prev,
reset,
start,
stop
};
};
There are differences between setInterval and setTimeout that you may not want to lose by always restarting your timer when the component re-renders. This fiddle shows the difference in drift between the two when other code is also running. (On older browsers/machines—like from when I originally answered this question—you don't even need to simulate a large calculation to see a significant drift begin to occur after only a few seconds.)
Referring now to your answer, Marco, the use of setInterval is totally lost because effects without conditions dispose and re-run every time the component re-renders. So in your first example, the use of the current dependency causes that effect to dispose and re-run every time the current changes (every time the interval runs). The second one does the same thing, but actually every time any state changes (causing a re-render), which could lead to some unexpected behavior. The only reason that one works is because next() causes a state change.
Considering the fact that you are probably not concerned with exact timing, is is cleanest to use setTimeout in a simple fashion, using the current and auto vars as dependencies. So to re-state part of your answer, do this:
useEffect(
() => {
if (!auto) return;
const interval = setTimeout(_ => {
next();
}, autoInterval);
return _ => clearTimeout(interval);
},
[auto, current]
);
Generically, for those just reading this answer and want a way to do a simple timer, here is a version that doesn't take into account the OP's original code, nor their need for a way to start and stop the timer independently:
const [counter, setCounter] = useState(0);
useEffect(
() => {
const id= setTimeout(() => {
setCounter(counter + 1);
// You could also do `setCounter((count) => count + 1)` instead.
// If you did that, then you wouldn't need the dependency
// array argument to this `useEffect` call.
}, 1000);
return () => {
clearTimeout(id);
};
},
[counter],
);
However, you may be wondering how to use a more exact interval, given the fact that setTimeout can drift more than setInterval. Here is one method, again, generic without using the OP's code:
// Using refs:
const [counter, setCounter] = useState(30);
const r = useRef(null);
r.current = { counter, setCounter };
useEffect(
() => {
const id = setInterval(() => {
r.current.setCounter(r.current.counter + 1);
}, 1000);
return () => {
clearInterval(id);
};
},
[] // empty dependency array
);
// Using the function version of `setCounter` is cleaner:
const [counter, setCounter] = useState(30);
useEffect(
() => {
const id = setInterval(() => {
setCounter((count) => count + 1);
}, 1000);
return () => {
clearInterval(id);
};
},
[] // empty dependency array
);
Here is what is going on above:
(first example, using refs): To get setInterval's callback to always refer to the currently acceptable version of setCounter we need some mutable state. React gives us this with useRef. The useRef function will return an object that has a current property. We can then set that property (which will happen every time the component re-renders) to the current versions of counter and setCounter.
(second example, using functional setCounter): Same idea as the first, except that when we use the function version of setCounter, we will have access to the current version of the count as the first argument to the function. No need to use a ref to keep things up to date.
(both examples, continued): Then, to keep the interval from being disposed of on each render, we add an empty dependency array as the second argument to useEffect. The interval will still be cleared when the component is unmounted.
Note: I used to like using ["once"] as my dependency array to indicate that I am forcing this effect to be set up only once. It was nice for readability at the time, but I no longer use it for two reasons. First, hooks are more widely understood these days and we have seen the empty array all over the place. Second, it clashes with the very popular "rule of hooks" linter which is quite strict about what goes in the dependency array.
So applying what we know to the OP's original question, you could use setInterval for a less-likely-to-drift slideshow like this:
// ... OP's implementation code including `autoInterval`,
// `auto`, and `next` goes above here ...
const r = useRef(null);
r.current = { next };
useEffect(
() => {
if (!auto) return;
const id = setInterval(() => {
r.current.next();
}, autoInterval);
return () => {
clearInterval(id);
};
},
[auto]
);
Because the current value is going to change on every "interval" as long as it should be running, then your code will start and stop a new timer on every render. You can see this in action here:
https://codesandbox.io/s/03xkkyj19w
You can change setInterval to be setTimeout and you will get the exact same behaviour. setTimeout is not a persistent clock, but it doesn't matter since they both get cleaned up anyways.
If you do not want to start any timer at all, then put the condition before setInterval not inside of it.
useEffect(
() => {
let id;
if (run) {
id = setInterval(() => {
setValue(value + 1)
}, 1000);
}
return () => {
if (id) {
alert(id) // notice this runs on every render and is different every time
clearInterval(id);
}
};
}
);
So far, it seems that both solutions below work as desired:
Conditionally creating timer — it requires that useEffect is dependent both on auto and current to work
useEffect(
() => {
if (!auto) return;
const interval = setInterval(_ => {
next();
}, autoInterval);
return _ => clearInterval(interval);
},
[auto, current]
);
Conditionally executing update to state — it does not require useEffect dependencies
useEffect(() => {
const interval = setInterval(_ => {
if (auto) {
next();
} else {
// do nothing
}
}, autoInterval);
return _ => clearInterval(interval);
});
Both solutions work if we replace setInterval by setTimeout
You could use useTimeout hook that returns true after specified number of milliseconds.
https://github.com/streamich/react-use/blob/master/docs/useTimeout.md

Resources