I am trying to build a debounce hook. I have seen several implementations before but none of them suit my needs: usually they delay executing a handler until the attempts to call the handler stop long enough.
useEffect(() => {
timeout = setTimeout(handler, 500);
return () => {
if (timeout){
clearTimeout(timeout);
}
}
}, [handler]);
(or something like that.) I think this is flawed, because if the intent is to avoid spamming a long-running function, it doesn't take into account whether the function returns within the timeout or not. What if fetching search results takes longer than 500ms in this case?
Instead, I want to try and run a long running function (the handler.) If there isn't one running, execute the handler and return its promise. Also, use the finally block to check to see if the input has changed, and if so, fire the handler again.
My desired usage:
const [input, setInput] = useState<string>("");
const debouncedPromise = useDebounce(() => asyncFunction(input), [input]);
Anytime the input changes, the handler could be queued up if it isn't already running.
This is the code I've written:
import { DependencyList, useEffect, useRef, useState } from "react";
interface IState<T> {
handler?: () => Promise<T>;
promise?: Promise<T>;
isWaiting: boolean;
}
export const useDebounce = <T>(handler: () => Promise<T>, deps: DependencyList = []): Promise<T> | undefined => {
const [state, setState] = useState<IState<T>>({
handler,
isWaiting: false
});
const stopWaiting = () => {
console.log("stopWaiting");
setState(previousState => ({ ...previousState, waiting: false }));
};
const run = () => {
const promise = handler();
promise.finally(stopWaiting);
setState({
handler,
isWaiting: true,
promise,
});
};
useEffect(() => {
console.log("\nuseEffect");
console.log(`deps: ${deps}`)
console.log(`state.isWaiting: ${state.isWaiting}`);
console.log(`state.handler: ${state.handler}`);
console.log(`state.promise: ${state.promise}`);
if (state.isWaiting){
console.log(">>> state.isWaiting")
return;
}
if (handler === state.handler){
console.log(">>> handler === state.handler")
return;
}
if (state.isWaiting && state.promise && state.handler !== handler){
console.log(">>> state.isWaiting && state.promise && state.handler !== handler")
state.promise.finally(run);
return;
}
if (handler !== state.handler){
console.log(">>> handler !== state.handler")
run();
}
console.log("end useEffect");
}, [...deps, state.isWaiting]);
return state.promise;
};
It works for the first invocation, but it never seems to free up the state.isWaiting to allow subsequence, pending handlers to be fired:
useEffect UseDebounce.ts:32
deps: T UseDebounce.ts:33
state.isWaiting: false UseDebounce.ts:34
state.handler: function () {
return asyncFunction(input);
} UseDebounce.ts:35
state.promise: undefined UseDebounce.ts:36
>>> handler !== state.handler UseDebounce.ts:55
asyncFunction called with T UseDebounce.tsx:9
end useEffect UseDebounce.ts:59
useEffect UseDebounce.ts:32
deps: T UseDebounce.ts:33
state.isWaiting: true UseDebounce.ts:34
state.handler: function () {
return asyncFunction(input);
} UseDebounce.ts:35
state.promise: [object Promise] UseDebounce.ts:36
>>> state.isWaiting UseDebounce.ts:39
asyncFunction resolved with T UseDebounce.tsx:12
stopWaiting UseDebounce.ts:16
useEffect UseDebounce.ts:32
deps: Ti UseDebounce.ts:33
state.isWaiting: true UseDebounce.ts:34 // This should be false at this point
I think I'm stuck with a stale state. How can I resolve this? Is there a better hook I can use to get my desired results? And how can I stop firing the handler once the input "settles down"?
It never seems to free up the state.isWaiting. I think I'm stuck with a stale state.
I think your problem is rather too fresh state. In particular, you are calling the hook with a new, different handler every time, so the only time that handler === state.handler could be true is that initial that initialises the state with the same handler. You might need to use useCallback on the function you're passing.
But even then your code has a problem. If the input changes multiple times while the function still running, you will call state.promise.finally(run); multiple times, which schedules run (with, indeed, some stale handlers) multiples times on the same promise, causing them to execute at the same time.
Is there a better hook I can use to get my desired results?
I would not (only) use a state but rather a simple shared mutable ref for the input that's waiting to be used in the next function invocation. Also let the run function continue with the latest input by itself, instead of scheduling a fixed handler that's hard to cancel.
So I'd write
interface ResultState<T> {
running: boolean;
result?: PromiseSettledResult<T>;
}
function useAsyncEvaluation<T, Args extends DependencyList>(fn: (...args: Args) => Promise<T>, input: Args): ResultState<T> {
const args = useRef<Args | undefined>();
const [state, setState] = useState<ResultState<T>>({running: false});
function run() {
const next = args.current;
if (!next) return false;
args.current = undefined;
fn(...next).then(
value => ({status: "fulfilled", value}),
reason => ({status: "rejected", reason})
).then(result => {
setState({running: run(), result});
);
return true;
}
useEffect(() => {
args.current = input;
setState(old => {
if (old.running) {
return old;
} else {
// calling run() inside the state update prevents race conditions
return {running: run(), result: old.result};
}
});
return () => {
args.current = undefined;
};
}, input)
return state;
}
Notice that this has the slightly undesirable behaviour of updating state in an effect during the first render and in response to an input change. You might not actually want to do this anyway - rather, return the function from the hook and call it from your event handlers.
Related
Im trying to update and reference hasError state field inside of the initialization function of my component in order to control if a redirect happens after successful initialization or if error gets displayed.
Here is a condensed version of the issue:
const [hasError, setHasError] = useState(false);
useEffect(() => {
initialize();
}, []);
async function initialize(){
try {
await networkRequest();
} catch (err) {
setHasError(true);
}
console.log(hasError); // <- is still false
if(!hasError){
redirect() // <- causes redirect even with error
}
}
function networkRequest() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject();
}, 1000);
});
}
The initialization function should only be called once on component mount which is why I'm passing [] to useEffect. Passing [hasError] to useEffect also doesn't make sense since I don't want initialization to run everytime hasError updates.
I have seen people recommend using useReducer but that seems hacky since I'm already using Redux on this component and I'll need to use 2 different dispatch instances then.
How is a use case like this typically handled?
You will have to create another useEffect hook that "listens" for changes to hasError. setHasError in this case is asynchronous, so the new value won't be available to you immediately.
I don't know what the rest of your component looks like, but it sounds like you should have some sort of isLoading state that will be used to display a loading message, then once your request finishes and it fails, you render what you need, or if it succeeds, you redirect.
Here's an example:
function App() {
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
useEffect(() => {
(async () => {
try {
await networkRequest();
isLoading(false);
} catch (error) {
setHasError(error);
isLoading(false);
}
})()
}, [])
useEffect(() => {
if (!isLoading && !hasError) {
redirect();
}
}, [isLoading, hasError]);
if (isLoading) { return "Loading"; }
// else, do whatever you need to do here
}
i want to fire a callback after a text change, basically this is for search. My code:
const fetchMovies = useCallback(async () => {
console.log('fetchMovies api ');
const {Search} = await fetch(
`http://www.omdbapi.com/?apikey=${apiKey}&s=${text}&page=${page}`,
).then(data => data.json());
console.log('movies', Search);
return Search;
}, [page, text]);
useEffect(() => {
console.log('useEffect!');
if (timeout) {
clearTimeout(timeout);
}
if (text) {
const newTimeout = setTimeout(async () => {
dispatch(fetchMoviesRequest('fetch'));
console.log('fetch!1');
try {
const moviesResult = await fetchMovies();
console.log('fetch!2', moviesResult);
dispatch(fetchMoviesSuccess(moviesResult));
} catch (fetchError) {
console.log('fetch!3e', fetchError);
dispatch(fetchMoviesFailure(fetchError));
}
}, 2000);
dispatch(onSetTimeout(newTimeout));
}
return () => {
clearTimeout(timeout);
};
}, [fetchMovies, text, timeout, page]);
somehow i cannot figure out what causes it to rerender so much when it's supposed to rerender only after text change? i can only type 1 letter and app crashes with error of max call stack?
I'm not sure what the values of your other variables are in your useEffect dependency array, but what looks suspicious to me is that you have timeout as one of the dependencies.
I'm going on a hunch and say that this line onSetTimeout(newTimeout) will change the value of the timeout variable which will re-trigger this useEffect. This will create an infinite loop because the effect will run every time timeout changes.
Have you tried changing your useEffect's dependency list to only [text]?
I'm not too sure, but I think that'll only call the function if text changes.
I have this simple example that's giving me grief:
useEffect(() => {
axios.get(...).then(...).catch(...)
}, [props.foo])
warning: can't perform a react state update on an unmounted component
Done some research and this one is more understandable. TypeScript seems not to like that approach as useEffect should return a void.
useEffect(() => {
let isSubscribed = true
axios.get(...).then(...).catch(...)
return () => (isSubscribed = false)
}, [props.foo])
TypeScript:
/**
* Accepts a function that contains imperative, possibly effectful code.
*
* #param effect Imperative function that can return a cleanup function
* #param deps If present, effect will only activate if the values in the list change.
*
* #version 16.8.0
* #see https://reactjs.org/docs/hooks-reference.html#useeffect
*/
function useEffect(effect: EffectCallback, deps?: DependencyList): void;
How to implement an isSubscribed in my useEffect with TS?
Thanks.
useEffect itself returns void, but the function provided to useEffect is typed as EffectCallback. This is definied as:
// NOTE: callbacks are _only_ allowed to return either void, or a destructor.
// The destructor is itself only allowed to return void.
type EffectCallback = () => (void | (() => void | undefined));
Source
This means your effect callback can actually return a function, which has to return void or undefined.
Now you can solve your issue to avoid calling setState with the isSubscribed variable. But another (maybe better) way is to outright cancel the request.
useEffect(() => {
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get('...', { cancelToken: source.token }).then(/**/).catch(e => {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {/* handle error */}
});
return () => source.cancel();
}, [props.foo])
This is also documented in the README.
The problem with your current code is, that your destructor function returns the boolean isSubscribed. Instead of returning it, just put the assignment into a function body:
return () => {
isSubscribed = false;
}
Edit: It just occurred to me that there's likely no need to reset the variable within the useEffect hook. In fact, stateTheCausesUseEffectToBeInvoked's actual value is likely inconsequential. It is, for all intents and purposes, simply a way of triggering useEffect.
Let's say I have a functional React component whose state I initialize using the useEffect hook. I make a call to a service. I retrieve some data. I commit that data to state. Cool. Now, let's say I, at a later time, interact with the same service, except that this time, rather than simply retrieving a list of results, I CREATE or DELETE a single result item, thus modifying the entire result set. I now wish to retrieve an updated copy of the list of data I retrieved earlier. At this point, I'd like to again trigger the useEffect hook I used to initialize my component's state, because I want to re-render the list, this time accounting for the newly-created result item.
const myComponent = () => {
const [items, setItems] = ([])
useEffect(() => {
const getSomeData = async () => {
try {
const response = await callToSomeService()
setItems(response.data)
setStateThatCausesUseEffectToBeInvoked(false)
} catch (error) {
// Handle error
console.log(error)
}
}
}, [stateThatCausesUseEffectToBeInvoked])
const createNewItem = async () => {
try {
const response = await callToSomeService()
setStateThatCausesUseEffectToBeInvoked(true)
} catch (error) {
// Handle error
console.log(error)
}
}
}
I hope the above makes sense.
The thing is that I want to reset stateThatCausesUseEffectToBeInvoked to false WITHOUT forcing a re-render. (Currently, I end up calling the service twice--once for win stateThatCausesUseEffectToBeInvoked is set to true then again when it is reset to false within the context of the useEffect hook. This variable exists solely for the purpose of triggering useEffect and sparing me the need to elsewhere make the selfsame service request that I make within useEffect.
Does anyone know how this might be accomplished?
There are a few things you could do to achieve a behavior similar to what you described:
Change stateThatCausesUseEffectToBeInvoked to a number
If you change stateThatCausesUseEffectToBeInvoked to a number, you don't need to reset it after use and can just keep incrementing it to trigger the effect.
useEffect(() => {
// ...
}, [stateThatCausesUseEffectToBeInvoked]);
setStateThatCausesUseEffectToBeInvoked(n => n+1); // Trigger useEffect
Add a condition to the useEffect
Instead of actually changing any logic outside, you could just adjust your useEffect-body to only run if stateThatCausesUseEffectToBeInvoked is true.
This will still trigger the useEffect but jump right out and not cause any unnecessary requests or rerenders.
useEffect(() => {
if (stateThatCausesUseEffectToBeInvoked === true) {
// ...
}
}, [stateThatCausesUseEffectToBeInvoked]);
Assuming that 1) by const [items, setItems] = ([]) you mean const [items, setItems] = useState([]), and 2) that you simply want to reflect the latest data after a call to the API:
When the state of the component is updated, it re-renders on it's own. No need for stateThatCausesUseEffectToBeInvoked:
const myComponent = () => {
const [ items, setItems ] = useState( [] )
const getSomeData = async () => {
try {
const response = await callToSomeService1()
// When response (data) is received, state is updated (setItems)
// When state is updated, the component re-renders on its own
setItems( response.data )
} catch ( error ) {
console.log( error )
}
}
useEffect( () => {
// Call the GET function once ititially, to populate the state (items)
getSomeData()
// use [] to run this only on component mount (initially)
}, [] )
const createNewItem = async () => {
try {
const response = await callToSomeService2()
// Call the POST function to create the item
// When response is received (e.g. is OK), call the GET function
// to ask for all items again.
getSomeData()
} catch ( error ) {
console.log( error )
}
} }
However, instead of getting all items after every action, you could change your array locally, so if the create (POST) response.data is the newly created item, you can add it to items (create a new array that includes it).
I have built a useFetch function that is closely modeled after this: https://www.robinwieruch.de/react-hooks-fetch-data Here's a simplified version of it: How to correctly call useFetch function?
Note that once the fetch is initiated, isLoading is set to true.
I have a use case where a fetch call needs to go out only for an admin user. Here's the code I've added to my React component:
const [companies, companiesFetch] = useFetch(null, {});
if (appStore.currentAccessLevel === 99 && !companies.isLoading && newUsers.companies.length === 0) {
console.log('About to call companiesFetch');
companiesFetch(`${API_ROOT()}acct_mgmt/companies`);
}
useEffect(() => {
if (!companies.isLoading && companies.status === 200) {
newUsers.companies = companies.data.companies;
}
}, [companies.isLoading, companies.status, newUsers.companies, companies.data]);
The idea with the first if statement is to only make the fetch call if ALL of the following is true:
1. The currently logged in user is an admin.
2. companiesFetch hasn't been previously called.
3. newUsers.companies hasn't yet been populated.
This seems logical, but yet if I run this code, companiesFetch is called 25 times before the app crashes. I assume the problem is a timing issue, namely that companies.isLoading isn't set quickly enough.
How would you fix this?
Based on the simplified example you've provided in the link, the problem is the useEffect() within the implementation of useFetch() that calls fetchData() unconditionally after the first render. Given your use-case, you don't need that behavior since you're calling it manually based on the conditions you've enumerated.
In addition to this, you've exacerbated the problem by calling companiesFetch() directly in the functional component, which you're not supposed to do because fetchData() makes synchronous calls to setState() and modifies your component's state during rendering.
You can solve these issues by first modifying useFetch() to remove the unconditional fetch after the first render, and then by moving your conditional logic into a useEffect() callback within the component. Here's how I'd implement useFetch():
const initialFetchState = { isLoading: false, isError: false };
// automatically merge update objects into state
const useLegacyState = initialState => useReducer(
(state, update) => ({ ...state, ...update }),
initialState
);
export const useFetch = config => {
const instance = useMemo(() => axios.create(config), [config]);
const [state, setState] = useLegacyState(initialFetchState);
const latestRef = useRef();
const fetch = useCallback(async (...args) => {
try {
// keep ref for most recent call to fetch()
const current = latestRef.current = Symbol();
setState({ isError: false, isLoading: true });
const { data } = await instance.get(...args).finally(() => {
// cancel if fetch() was called again before this settled
if (current !== latestRef.current) {
return Promise.reject(new Error('cancelled by later call'));
}
});
setState({ data });
} catch (error) {
if (current === latestRef.current) {
setState({ isError: true });
}
} finally {
if (current === latestRef.current) {
setState({ isLoading: false });
}
}
}, [instance]);
return [state, fetch];
};
and here's how I'd call it within useEffect() to prevent synchronous calls to setState():
const [companies, companiesFetch] = useFetch({ baseURL: API_ROOT() });
const [newUsers, setNewUsers] = useState({ companies: [] });
useEffect(() => {
if (!companies.isLoading) {
if (appStore.currentAccessLevel === 99 && newUsers.companies.length === 0) {
console.log('About to call companiesFetch');
companiesFetch('acct_mgmt/companies');
} else if (companies.status === 200 && newUsers.companies !== companies.data.companies) {
setNewUsers({ companies: companies.data.companies });
}
}
}, [appStore.currentAccessLevel, companies, newUsers]);
In your example, I'm not sure what scope newUsers was declared in, but seeing newUsers.companies = ... within the useEffect() callback raised some concern, since the implication is that your functional component is impure, which can easily lead to subtle bugs.