Description
What I Need
I want to trigger several fetch-based Promises in parallel. Whenever each of these promises resolves, the value should be processed and appended to component state (items). In other words, I want the items state to be updated with each promise resolution, rather then one time when all promises resolve.
Issue
The issue is that if I let each promise have a .then() handler that processes own resolved value, then items state is being captured in each closure. This leads to first handler calling setState with [...items, firstResultItem], second -- with [...items, secondResultItem] and so on. Each closure is not seeing the changes made to items state from the other handler. This race condition results in latest-fetch-wins kind of situation, which is undesireable.
The issue is solved easily if I have a single promise handler applied to Promise.all(...), but this goes against the desired component behavior.
Thoughts
I was thinking about using useRef together with useState or find a way to leverage useEffect, but I think this is a typical problem that must already have an idiomatic solution.
Code
For simplicity, the fetches are faked with imitateFetch(promiseResolutionValue, delayInMs).
items has a default state of a single item; all the new items produced from imitateFetch should be appended to current state of items.
See Executable example on codesandbox
import { useRef, useState } from "react";
export interface Item {
id: string;
text: string;
}
export function App() {
const [items, setItems] = useState<Item[]>([
{ id: new Date().toISOString(), text: new Date().toLocaleTimeString() },
]);
const naiveUpdateItemListWith = (newItemTexts: string[]) => {
const newItems = newItemTexts.map(newItemText => {
const item: Item = {
id: new Date().toISOString(),
text: newItemText,
};
return item;
});
setItems([...items, ...newItems]);
};
const handleTriggerFetch = () => {
// Buggy variant.
const one = imitateFetch('one', 1000).then(text => naiveUpdateItemListWith([text]));
const two = imitateFetch('two', 1500).then(text => naiveUpdateItemListWith([text]));
const three = imitateFetch('three', 2000).then(text => naiveUpdateItemListWith([text]));
Promise.all([one, two, three]);
// These works, but the update happens once, when ALL promises resolve.
// const one = imitateFetch('one', 1000);
// const two = imitateFetch('two', 1500);
// const three = imitateFetch('three', 2000);
// Promise.all([one, two, three]).then(texts => naiveUpdateItemListWith(texts));
};
return (
<div>
{items.map(item => <div key={item.id}>[{item.id}] -- {item.text}</div>)}
<button onClick={handleTriggerFetch}>
Imitate parallel fetches
</button>
</div>
);
}
function imitateFetch<T>(valueToResolve: T, ms = 1000): Promise<T> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(valueToResolve);
}, ms);
});
}
Unless I misunderstand your question, the solution is actually simple. You can pass an updater function to your setItems function, which takes the previous state as an argument, and returns the new state. The updater function takes the pending state and calculates the next state from it, so it would eliminate race conditions.
setItems((i: Item[]) => [...i, ...newItems]);
Related
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.
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
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).
I know there is a scoping issue here. I just can't find it. Why is 'items' null in the searchItems() block?
export const useStore = () => {
const [items, setItems] = useState(null)
const setItemsFromApi = () => {
//call api to get data, then
setItems(data)
}
const searchItems = (query) => {
//use the local data and filter based on query
//'items' IS NULL IN THIS SCOPE
items.filter(() => {})
}
console.log(items) // 'items' HAS UPDATED VALUE AFTER setItemsFromApi() IN THIS SCOPE
return {setItemsFromApi, searchItems}
}
Use store like this. (NOTE: I left out the rendering of the items in the list because that part works fine. Just focusing on why the onClick doesn't use the already loaded items to filter with.)
export default function DataList(props) => {
const store = useStore();
useEffect(() => {
store.setItemsFromApi()
}, [])
const runSearch = (query) => {
store.searchItems(query)
}
return <button onClick={runSearch('searchTerm')}
}
I even tried passing it as a callback dependency, but it's still null
const searchItems = useCallback((query) => {
//'items' IS STILL NULL IN THIS SCOPE
items.filter(() => {})
}, [items])
From the code you posted,
const store = useStore()
store.setItemsFromApi()
...
store.searchItems(query)
the issue may be because you are doing an async operation (calling the API), but the search is not waiting for the result of the fetch call. So, when you do the
store.searchItems(query)
, the store is null and only changes its value later.
In a nutshell, the state wasn't refreshing after triggering a search because I had a "debounce" useRef function running within the component when the onChange event was fired, even though this was a local data search. I guess this interrupted the re-render event. So I removed it.
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.