Redundant piece of code using React hooks - javascript

There's a fixed number of settings that determine whether the component should be visible, i.e.:
const restrictions = {
isLogged: true, // TRUE stands for: check if the condition is met
hasMoney: true,
didWinYesterday: false,
}
For each restrictions key, I've created a state with useState and initialized them all with false, like:
const [isUserLogged, setIsUserLogged] = useState(false)
const [hasUserMoney, setHasUserMoney] = useState(false)
const [didUserWinYday, setDidUserWinYday] = useState(false)
Next, I am checking against each condition with useEffect and updating the state accordingly:
useEffect(() => {
const checkIfUserIsLogged = async () => {
// calling an API to get boolean
const isLogged = await API.call()
setIsUserLogged(isLogged)
}
// If the restriction is set to false, ignore checking and set relevant state
if (!restrictions.isLogged) {
setIsUserLogged(true)
return
}
checkIfUserIsLogged()
}, [restrictions.isLogged])
Finally, I am checking if I should render the actual component or should I break early like so:
if (!isUserLogged) return <p>User not logged in.</p>
The useEffect code and the check above is repeated 3 times in total. Each of the repetition is making different API call and is updating different state, but the overall structure stays the same.
I wish I could do it more DRY but not sure how to get started. Any tips are welcome, thanks!

I'd refactor a single atom into a custom useRestrictionState atom that internally deals with the effect:
const restrictions = {
isLogged: true, // TRUE stands for: check if the condition is met
hasMoney: true,
didWinYesterday: false,
};
function useRestrictionState(restrictionFlag, apiFunc) {
const [flag, setFlag] = React.useState(undefined);
useEffect(() => {
if (!restrictionFlag) {
setFlag(true);
} else {
apiFunc().then((result) => setFlag(result));
}
}, [restrictionFlag]);
return flag;
}
function Component() {
const isUserLogged = useRestrictionState(restrictions.isLogged, getLoginState);
const hasUserMoney = useRestrictionState(restrictions.hasMoney, getUserMoney);
const didUserWinYday = useRestrictionState(restrictions.didWinYesterday, getUserDidWinYesterday);
}
If you always need all of them, you can naturally wrap this in another custom hook:
function useUserRestrictionState(restrictions) {
const isUserLogged = useRestrictionState(restrictions.isLogged, getLoginState);
const hasUserMoney = useRestrictionState(restrictions.hasMoney, getUserMoney);
const didUserWinYday = useRestrictionState(restrictions.didWinYesterday, getUserDidWinYesterday);
return { isUserLogged, hasUserMoney, didUserWinYday };
}
function Component() {
const { isUserLogged, hasUserMoney, didUserWinYday } = useUserRestrictionState(restrictions);
}

Here's my take:
import { useState, useEffect } from 'react'
const useCheck = (condition: boolean, performCheck: () => boolean): boolean => {
const [isConditionMet, setIsConditionMet] = useState<boolean>(false)
useEffect(() => {
const check = async () => {
const isConditionMet = await performCheck()
setIsConditionMet(isConditionMet)
}
if (!condition) {
setIsConditionMet(true)
}
check()
}, [condition, performCheck])
return isConditionMet
}
export default useCheck
Usage:
const isUserLogged = useCheck(restrictions.isLogged, () => true) // 2nd parameter should be an API call in my case

Related

How do I avoid a useEffect loop when the dep is a function from a default parameter?

I'm writing a video component and I've run into a useEffect rendering loop with an event function prop called onParentNotify.
function VideoPlayer({
videoPath = "",
onNotifyParent = () => null, // <-- this is where the problem is.
}) {
const [status, setStatus] = useState(null);
const videoRef = useRef();
const isLoading = status?.isLoading;
useEffect(() => {
if (!isLoading) {
onNotifyParent("log has loaded!");
}
}, [isLoading, videoPath, onNotifyParent]);
return (<Video
ref={videoRef}
videoPath={videoPath}
onStatus={(status) => setStatus(status)}
/>)
}
I memoized this function in the outer ParentComponent1 which prevented the loop.
function ParentComponent1() {
// Yay, works!
const handleNotifyParent = useCallback((msg = "") => {
console.log(msg);
});
return (<VideoPlayer
videoPath="http://video-url-here.com"
onNotifyParent={handleNotifyParent}
>);
}
But with ParentComponent2, that component doesn't pass the prop, so it defaults to onNotifyParent = () => null in the props declaration for VideoPlayer, causing the loop.
// Eeek! Rendering loops ahead
function ParentComponent2() {
return <VideoPlayer videoPath="http://video-url-here.com">;
}
My question is: how should I go about memoizing the default prop parameter onNotifyParent when the prop is undefined? Two solutions come to mind, but they seem a bit clunky:
Define default function outside the component scope.
const DEFAULT_FUNC = () => null;
function VideoPlayer({
videoPath = "",
onNotifyParent = DEFAULT_FUNC, // ew
onSomethingElse = DEFAULT_FUNC,
onAnotherThing = DEFAULT_FUNC,
}) {
// ...
}
Pass null as the default and call the function conditionally.
function VideoPlayer({
videoPath = "",
onNotifyParent = null
}) {
useEffect(() => {
if (!isLoading) {
onNotifyParent?.("log has loaded!"); // feels wrong...
}
}, [isLoading, videoPath, onNotifyParent]);
}
Or is there a better/different convention to follow in this case?
Based on the discussion here, I've decided to go with option #2 - passing null as the default and calling the function conditionally with null coalescing:
function VideoPlayer({
videoPath = "",
onNotifyParent = null, // <-- here
}) {
useEffect(() => {
if (!isLoading) {
onNotifyParent?.("log has loaded!"); // <-- and here
}
}, [isLoading, videoPath, onNotifyParent]);
}
In future I may turn to TypeScript or React prop-types and end up with a solution that resembles option #1, but for the time being I'm just going to keep things simple and avoid setting default parameter functions. Thanks all for your time today.

Getting stale value of local state variable after response returned from promise

I have a react application with two buttons, which on click load user name from server. The behaviour works if I click buttons one at a time and wait for response, however, if I click both, the response from API for second button writes value to state which is stale due to which the first button gets stuck in loading state. How can I resolve this to always have latest data when promise resolves?
Code sandbox demo: https://codesandbox.io/s/pensive-frost-qkm9xh?file=/src/App.js:0-1532
import "./styles.css";
import LoadingButton from "#mui/lab/LoadingButton";
import { useRef, useState } from "react";
import { Typography } from "#mui/material";
const getUsersApi = (id) => {
const users = { "12": "John", "47": "Paul", "55": "Alice" };
return new Promise((resolve) => {
setTimeout((_) => {
resolve(users[id]);
}, 1000);
});
};
export default function App() {
const [users, setUsers] = useState({});
const availableUserIds = [12, 47];
const loadUser = (userId) => {
// Mark button as loading
const updatedUsers = { ...users };
updatedUsers[userId] = {
id: userId,
name: undefined,
isLoading: true,
isFailed: false
};
setUsers(updatedUsers);
// Call API
getUsersApi(userId).then((userName) => {
// Update state with user name
const updatedUsers = { ...users };
updatedUsers[userId] = {
...updatedUsers[userId],
name: userName,
isLoading: false,
isFailed: false
};
setUsers(updatedUsers);
});
};
return (
<div className="App">
{availableUserIds.map((userId) =>
users[userId]?.name ? (
<Typography variant="h3">{users[userId].name}</Typography>
) : (
<LoadingButton
key={userId}
loading={users[userId]?.isLoading}
variant="outlined"
onClick={() => loadUser(userId)}
>
Load User {userId}
</LoadingButton>
)
)}
</div>
);
}
The problem is that useState's setter is asynchronous, so, in your loader function, when you define const updatedUsers = { ...users };, user is not necessary updated.
Luckily, useState's setter provides allows us to access to the previous state.
If you refactor your code like this, it should work:
const loadUser = (userId) => {
// Mark button as loading
const updatedUsers = { ...users };
updatedUsers[userId] = {
id: userId,
name: undefined,
isLoading: true,
isFailed: false
};
setUsers(updatedUsers);
// Call API
getUsersApi(userId).then((userName) => {
// Update state with user name
setUsers(prevUsers => {
const updatedUsers = { ...prevUsers };
updatedUsers[userId] = {
...updatedUsers[userId],
name: userName,
isLoading: false,
isFailed: false
};
return updatedUsers
});
});
};
Here a React playground with a simplified working version.

React Context - State value is not up-to-date inside a function

I have the following context:
import React, { createContext, useState } from "react";
const OtherUsersContext = createContext(null);
export default OtherUsersContext;
export function OtherUsersProvider({ children }) {
const [otherUsers, setOtherUsers] = useState(new Map([]));
const addUser = (userId, userData) => {
setOtherUsers(
(prevOtherUsers) => new Map([...prevOtherUsers, [userId, userData]])
);
};
const updateUser = (userId, userData, merge = true) => {
...
};
const getUser = (userId) => otherUsers.get(userId);
const resetUsers = () => {
setOtherUsers(new Map([]));
};
return (
<OtherUsersContext.Provider
value={{
addUser,
updateUser,
getUser,
resetUsers,
}}
>
{children}
</OtherUsersContext.Provider>
);
}
In my app, when a user signs out, I need to reset this context's map, using the function "resetUsers".
Currently this is working good, but there has no sense to reset the map if it has no values, so I have changed the "resetUsers" function to:
const resetUsers = () => {
if(otherUsers.size) {
setOtherUsers(new Map([]));
}
}
And, this is not working good, because inside resetUsers, otherUsers.size is always 0. Something which disturbs me because outside the function, the value is the correct one...
...
const resetUsers = () => {
console.log(otherUsers.size); // 0
setOtherUsers(new Map([]));
};
console.log(otherUsers.size); // 5
return ( ...
Any ideas?
The functional updates part of the hooks docs. says:
If the new state is computed using the previous state, you can pass a function to setState.
So instead of just passing the new value to your setter, you can pass a function that depends on the previous state.
This means that you can do:
const resetUsers = () => {
setOtherUsers(prevOtherUsers => prevOtherUsers.size ? new Map([]): prevOtherUsers);
}
One tip, if you are not getting the most updated state value inside a function, then wrap it inside an useCallback.
Try this:
const resetUsers = useCallback(() => {
if (otherUsers.size > 0) {
console.log(otherUsers.size); // 5
setOtherUsers(new Map([]));
}
}, [otherUsers]);

React useCallback linting error missing dependency

I am using a custom hook useInstantSearch in my component.
When I wrap it in useCallback to I get the following error:
React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead.
This is the code:
const [searchTerm, setSearchTerm] = useState<string>(searchQuery);
const handleDebouncedSearch = useCallback(
useInstantSearch(searchTerm, (search, cancelTrigger, searchStillValid) => {
console.log('instant search', search);
}),
[]
);
useEffect((): void => {
handleDebouncedSearch(searchTerm);
}, [searchTerm, handleDebouncedSearch]);
So effectively to send the updated search term to a child component for display only then the parent handles the debouncing of the search when that term changes.
search, cancelTrigger, searchStillValid
Are not part of the parent component, they are part of useInstantSearch.
Is this a warning I can ignore?
import { useEffect, useRef } from 'react';
import { CancelTrigger } from '../../../ars/api/api.cancel';
const DELAY_SEARCH_MS = 300;
interface InstantSearchOnChange {
(search: string, cancelTrigger: CancelTrigger, resultStillValid: { (): boolean }): void;
}
/**
* Helper to delay request until user stop typing (300ms), support deprecated requests (cancel and helper to not update the state), or unmounted component.
*/
export default function useInstantSearch(initialSearch: string, onChange: InstantSearchOnChange): { (value: string): void } {
const search = useRef<string>(initialSearch);
const requests = useRef<CancelTrigger[]>([]);
const mounted = useRef<boolean>(true);
useEffect(() => {
return (): void => {
mounted.current = false;
};
}, []);
return value => {
search.current = value;
setTimeout(() => {
if (search.current === value) {
requests.current = requests.current.filter(r => !r.cancel());
const trigger = new CancelTrigger();
requests.current.push(trigger);
onChange(value, trigger, () => search.current === value && mounted.current);
}
}, DELAY_SEARCH_MS);
};
}
Since you're using some external function, you can simply ignore the message:
useCallback(
useInstantSearch(...)
, []) // eslint-disable-line react-hooks/exhaustive-deps
However, you should be using it like:
const [searchTerm, setSearchTerm] = useState<string>(searchQuery);
const handleDebouncedSearch = useCallback(() => { // -> this
useInstantSearch(searchTerm, (search, cancelTrigger, searchStillValid) => {
console.log('instant search', search);
})
}, [searchTerm]); // eslint-disable-line react-hooks/exhaustive-deps
Eslint comment is required here because, you're using callback inside useInstantSearch as there's no way to inject them as dependency.
You can ignore it if you don't mind the stale closures you can do it that way:
const { useRef, useCallback, useEffect } = React;
const DELAY_SEARCH_MS = 300;
const later = (value, time) =>
new Promise((resolve) =>
setTimeout(() => resolve(value), time)
);
/**
* Helper to delay request until user stop typing (300ms), support deprecated requests (cancel and helper to not update the state), or unmounted component.
*/
function useInstantSearch(onChange) {
const timeout = useRef();
const mounted = useRef(true);
useEffect(() => {
return () => {
mounted.current = false;
};
}, []);
return useCallback(
(value) => {
clearTimeout(timeout.current); //cancel other
timeout.current = setTimeout(() => {
const current = timeout.current;
onChange(
value,
() =>
//comparing timeout.current with current
// async function may not be the last to resolve
// this is important when you want to set state based
// on an async result that is triggered on user input
// user types "a" and then "b" if 2 async searches start
// "a" and "ab" and "a" is so slow it will resolve after "ab"
// then state result will be set for "ab" first and then with "a"
// causing UI to be out of sync because user searched for "ab"
// but results for "a" are shown
timeout.current === current && mounted.current
);
}, DELAY_SEARCH_MS);
},
[onChange]
);
}
const App = () => {
const handler1 = useCallback(
(value) => console.log('handler1:', value),
[]
);
const handler2 = useCallback(
(value) => console.log('handler2:', value),
[]
);
const handler3 = useCallback((value, shouldResolve) => {
console.log('starting async with:', value);
return later(
value,
value.length === 1 ? 1000 : 800
).then(
(resolve) =>
shouldResolve() &&//you can opt not to set state here
console.log('resolved with', resolve)
);
}, []);
const debounced1 = useInstantSearch(handler1);
const debounced2 = useInstantSearch(handler2);
const debounced3 = useInstantSearch(handler3);
[1, 2, 3].forEach(
(num) =>
setTimeout(() => {
debounced1(num);
debounced2(num * 2);
}, 100) //lower than the 300 of debounce
);
//both callbacks will be called but "a" resolves after "ab" since
// "ab" was the last to be requested it will be the only one that logs
// resolved with
setTimeout(() => debounced3('a'), 500);
setTimeout(() => debounced3('ab'), 1500);
return 'hello world (check console)';
};
ReactDOM.render(<App />, 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>
<div id="root"></div>
There may be a solution to your problem but without knowing what useInstantSearch is it's impossible to provide one.
My guess is that you should use useCallback inside useInstantSearch but since that code is missing from your question I can only guess.

react useEffect comparing objects

I am using react useEffect hooks and checking if an object has changed and only then run the hook again.
My code looks like this.
const useExample = (apiOptions) => {
const [data, updateData] = useState([]);
useEffect(() => {
const [data, updateData] = useState<any>([]);
doSomethingCool(apiOptions).then(res => {
updateData(response.data);
})
}, [apiOptions]);
return {
data
};
};
Unfortunately it keeps running as the objects are not being recognised as being the same.
I believe the following is an example of why.
const objA = {
method: 'GET'
}
const objB = {
method: 'GET'
}
console.log(objA === objB)
Perhaps running JSON.stringify(apiOptions) works?
Use apiOptions as state value
I'm not sure how you are consuming the custom hook but making apiOptions a state value by using useState should work just fine. This way you can serve it to your custom hook as a state value like so:
const [apiOptions, setApiOptions] = useState({ a: 1 })
const { data } = useExample(apiOptions)
This way it's going to change only when you use setApiOptions.
Example #1
import { useState, useEffect } from 'react';
const useExample = (apiOptions) => {
const [data, updateData] = useState([]);
useEffect(() => {
console.log('effect triggered')
}, [apiOptions]);
return {
data
};
}
export default function App() {
const [apiOptions, setApiOptions] = useState({ a: 1 })
const { data } = useExample(apiOptions);
const [somethingElse, setSomethingElse] = useState('default state')
return <div>
<button onClick={() => { setApiOptions({ a: 1 }) }}>change apiOptions</button>
<button onClick={() => { setSomethingElse('state') }}>
change something else to force rerender
</button>
</div>;
}
Alternatively
You could write a deep comparable useEffect as described here:
function deepCompareEquals(a, b){
// TODO: implement deep comparison here
// something like lodash
// return _.isEqual(a, b);
}
function useDeepCompareMemoize(value) {
const ref = useRef()
// it can be done by using useMemo as well
// but useRef is rather cleaner and easier
if (!deepCompareEquals(value, ref.current)) {
ref.current = value
}
return ref.current
}
function useDeepCompareEffect(callback, dependencies) {
useEffect(
callback,
dependencies.map(useDeepCompareMemoize)
)
}
You can use it like you'd use useEffect.
I just found a solution which works for me.
You have to use usePrevious() and _.isEqual() from Lodash.
Inside the useEffect(), you put a condition if the previous apiOptions equals to the current apiOptions. If true, do nothing. If false updateData().
Example :
const useExample = (apiOptions) => {
const myPreviousState = usePrevious(apiOptions);
const [data, updateData] = useState([]);
useEffect(() => {
if (myPreviousState && !_.isEqual(myPreviousState, apiOptions)) {
updateData(apiOptions);
}
}, [apiOptions])
}
usePrevious(value) is a custom hook which create a ref with useRef().
You can found it from the Official React Hook documentation.
const usePrevious = value => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
If the input is shallow enough that you think deep equality would still be fast, consider using JSON.stringify:
const useExample = (apiOptions) => {
const [data, updateData] = useState([]);
const apiOptionsJsonString = JSON.stringify(apiOptions);
useEffect(() => {
const apiOptionsObject = JSON.parse(apiOptionsJsonString);
doSomethingCool(apiOptionsObject).then(response => {
updateData(response.data);
})
}, [apiOptionsJsonString]);
return {
data
};
};
Note it won’t compare functions.
If you're real sure that you cannot control apiOptions then just replace native useEffect with https://github.com/kentcdodds/use-deep-compare-effect.
It's reallllly so simple in some case!
const objA = {
method: 'GET'
}
const objB = {
method: 'GET'
}
console.log(objA === objB) // false
Why objA not equal with objB? Coz JS just compare their address right? They are two diffirent obj. That's we all know!
The same as React hooks does!
So, also as we all know, objA.method === objB.method right? Coz they are literal.
The answer comes out:
React.useEffect(() => {
// do your facy work
}, [obj.method])
You can use useDeepCompareEffect, useCustomCompareEffect or write your own hook.
https://github.com/kentcdodds/use-deep-compare-effect
https://github.com/sanjagh/use-custom-compare-effect
One other option, if you have the ability to modify doSomethingCool:
If know exactly which non-Object properties you need, you can limit the list of dependencies to properties that useEffect will correctly interpret with ===, e.g.:
const useExample = (apiOptions) => {
const [data, updateData] = useState([]);
useEffect(() => {
const [data, updateData] = useState<any>([]);
doSomethingCool(apiOptions.method).then(res => {
updateData(response.data);
})
}, [apiOptions.method]);
return {
data
};
};

Categories