React Hooks - keep arguments reference in state - reactjs

I created a hook to use a confirm dialog, this hook provides the properties to the component to use them like this:
const { setIsDialogOpen, dialogProps } = useConfirmDialog({
title: "Are you sure you want to delete this group?",
text: "This process is not reversible.",
buttons: {
confirm: {
onPress: onDeleteGroup,
},
},
width: "360px",
});
<ConfirmDialog {...dialogProps} />
This works fine, but also I want to give the option to change these properties whenever is needed without declaring extra states in the component where is used and in order to achieve this what I did was to save these properties in a state inside the hook and this way provide another function to change them if needed before showing the dialog:
interface IState {
isDialogOpen: boolean;
dialogProps: TDialogProps;
}
export const useConfirmDialog = (props?: TDialogProps) => {
const [state, setState] = useState<IState>({
isDialogOpen: false,
dialogProps: {
...props,
},
});
const setIsDialogOpen = (isOpen = true) => {
setState((prevState) => ({
...prevState,
isDialogOpen: isOpen,
}));
};
// Change dialog props optionally before showing it
const showConfirmDialog = (dialogProps?: TDialogProps) => {
if (dialogProps) {
const updatedProps = { ...state.dialogProps, ...dialogProps };
setState((prevState) => ({
...prevState,
dialogProps: updatedProps,
}));
}
setIsDialogOpen(true);
};
return {
setIsDialogOpen,
showConfirmDialog,
dialogProps: {
isOpen: state.isDialogOpen,
onClose: () => setIsDialogOpen(false),
...state.dialogProps,
},
};
};
But the problem here is the following:
Arguments are passed by reference so if I pass a function to the button (i.e onDeleteGroup) i will keep the function updated to its latest state to perform the correct deletion if a group id changes inside of it.
But as I'm saving the properties inside a state the reference is lost and now I only have the function with the state which it was declared at the beginning.
I tried to add an useEffect to update the hook state when arguments change but this is causing an infinite re render:
useEffect(() => {
setState((prevState) => ({
...prevState,
dialogProps: props || {},
}));
}, [props]);
I know I can call showConfirmDialog and pass the function to update the state with the latest function state but I'm looking for a way to just call the hook, declare the props and not touch the dialog props if isn't needed.
Any answer is welcome, thank you for reading.

You should really consider not doing this, this is not a good coding pattern, this unnecessarily complicates your hook and can cause hard to debug problems. Also this goes against the "single source of truth" principle. I mean a situation like the following
const Component = ({title}: {title?: string}) => {
const {showConfirmDialog} = useConfirmDialog({
title,
// ...
})
useEffect(() => {
// Here you expect the title to be "title"
if(something) showConfirmDialog()
}, [])
useEffect(() => {
// Here you expect the title to be "Foo bar?"
if(somethingElse) showConfirmDialog({title: 'Foo bar?'})
}, [])
// But if the second dialog is opened, then the first, the title will be
// "Foo bar?" in both cases
}
So please think twice before implementing this, sometimes it's better to write a little more code but it will save you a lot debugging.
As for the answer, I would store the props in a ref and update them on every render somehow like this
/** Assign properties from obj2 to obj1 that are not already equal */
const assignChanged = <T extends Record<string, unknown>>(obj1: T, obj2: Partial<T>, deleteExcess = true): T => {
if(obj1 === obj2) return obj1
const result = {...obj1}
Object.keys(obj2).forEach(key => {
if(obj1[key] !== obj2[key]) {
result[key] = obj2[key]
}
})
if(deleteExcess) {
// Remove properties that are not present on obj2 but present on obj1
Object.keys(obj1).forEach(key => {
if(!obj2.hasOwnProperty(key)) delete result[key]
})
}
return result
}
const useConfirmDialog = (props) => {
const localProps = useRef(props)
localProps.current = assignChanged(localProps.current, props)
const showConfirmDialog = (changedProps?: Partial<TDialogProps>) => {
localProps.current = assignChanged(localProps.current, changedProps, false)
// ...
}
// ...
}
This is in case you have some optional properties in TDialogProps and you want to accept Partial properties in showConfirmDialog. If this is not the case, you could simplify the logic a little by removing this deleteExcess part.
You see that it greatly complicates your code, and adds a performance overhead (although it's insignificant, considering you only have 4-5 fields in your dialog props), so I really recommend against doing this and just letting the caller of useConfirmDialog have its own state that it can change. Or maybe you could remove props from useConfirmDialog in the first place and force the user to always pass them to showConfirmDialog, although in this case this hook becomes kinda useless. Maybe you don't need this hook at all, if it only contains the logic that you have actually shown in the answer? It seems like pretty much the only thing it does is setting isDialogOpen to true/false. Whatever, it's your choice, but I think it's not the best idea

Related

React JS & Recoil set/get on select/selectFamily for specific attribute

I use Recoil state management in ReactJS to preserve a keyboard letters data, for example
lettersAtom = atom(
key: 'Letters'
default: {
allowed : ['A','C','D']
pressedCounter : {'A':2, 'D':5}
}
)
lettersPressedSelect = selector({
key: 'LettersPressed',
get: ({ get }) => get(lettersAtom).pressedCounter, //Not work, returns undefined
set: () => ({ set }, pressedLetter) => {
let newState = {...lettersAtom};
newState.pressedCounter[pressedLetter]++;
set(lettersAtom, newState);
}
}),
In functional component i use
const [letters,setLetters] = useRecoilState(lettersAtom);
const [pressedCounter, setPressedCounter] = useRecoilState(lettersPressedSelect);
each time the a keyboard letter pressed the pressedCounter I want to increased for corresponded letter like that
setPressedCounter('A');
setPressedCounter('C'); ///etc...
How to achieve that ? Does recoil have a way to get/set a specific part/sub of json attribute ? (without make another atom? - I want to keep "Single source of truth")
Or do you have a suggetion better best practice to do that ?
There are some bugs in your code: no const, braces in atom call and no get inside the set. You also need spread the pressedCounter.
Overwise your solution works fine.
In Recoil you update the whole atom. So in this particular case you probably don't need the selector. Here is a working example with both approaches:
https://codesandbox.io/s/modest-wind-kosp7o?file=/src/App.js
It a best-practice to keep atom values rather simple.
You can update the state based on the existing state in a selector in a couple ways. You could use the get() callback from the setter or you could use the updater form of the setter where you pass a function as the new value which receives the current value as a parameter.
However, it's a good practice to have symmetry for the getter and setters of a selector. For example, here's a selector family which gets and sets the value of a counter:
const lettersPressedState = selectorFamily({
key: 'LettersPressed',
get: letter => ({ get }) => get(lettersAtom).pressedCounter[letter],
set: letter => ({ set }, newPressedValue) => {
set(lettersAtom, existingLetters => ({
...existingLetters,
pressedCounter: {
...existingLetters.pressedCounter,
[letter]: newPressedValue,
},
});
},
});
But note that the above will set the new value with a new counter value where you originally wanted the setter to increment the value. That's not really setting a new value and is more like an action. For that you don't really need a selector abstraction at all and can just use an updater when setting the atom:
const [letters, setLetters] = useRecoilState(lettersAtom);
const incrementCounter = pressedLetter =>
setLetters(existingLetters => ({
...existingLetters,
pressedCounter: {
...existingLetters.pressedCounter,
[pressedLetter]: (existingLetters.pressedCounter[pressedLetter] ?? 0) + 1,
},
});
Note that this uses the updater form of the selector to ensure it is incrementing based on the current value and not a potentially stale value as of the rendering.
Or, you can potentially simplify things more and use simpler values in the atoms by using an atom family for the pressed counter:
const pressedState = atomFamily({
key: 'LettersPressed',
default: 0,
});
And you can update in your component like the following:
const [counter, setCounter] = useRecoilState(pressedState(letter));
const incrementCounter = setCounter(x => x + 1);
Or create an general incrementor callback:
const incrementCounter = useRecoilCallback(({ set }) => pressedLetter => {
set(pressedState(pressedLetter)), x => x + 1 );
});
So the sort answer help by user4980215 is:
set: () => ({ get, set }, pressedLetter) => {
let newState = {...get(lettersAtom)};
newState.pressedCounter[pressedLetter]++;
set(lettersAtom, newState);
}

Infinite scroll with RTK (redux-toolkit)

I'm struggling to implement full support of infinite scroll with cursor pagination, adding new elements and removing. I have used great example from github discussion https://github.com/reduxjs/redux-toolkit/discussions/1163#discussioncomment-876186 with few adjustments based on my requirements. Here is my implementation of useInfiniteQuery.
export function useInfiniteQuery<
ListOptions,
ResourceType,
ResultType extends IList<ResourceType> = IList<ResourceType>,
Endpoint extends QueryHooks<
QueryDefinition<any, any, any, ResultType, any>
> = QueryHooks<QueryDefinition<any, any, any, ResultType, any>>,
>(
endpoint: Endpoint,
{
options,
getNextPageParam,
select = defaultSelect,
inverseAppend = false,
}: UseInfiniteQueryOptions<ListOptions, ResultType, ResourceType>,
) {
const nextPageRef = useRef<string | undefined>(undefined);
const resetRef = useRef(true);
const [pages, setPages] = useState<ResourceType[] | undefined>(undefined);
const [trigger, result] = endpoint.useLazyQuery();
const next = useCallback(() => {
if (nextPageRef.current !== undefined) {
trigger(
{ options: { ...options, page_after: nextPageRef.current } },
true,
);
}
}, [trigger, options]);
useEffect(() => {
resetRef.current = true;
trigger({ options }, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, Object.values(options));
useEffect(() => {
if (!result.isSuccess) {
return;
}
nextPageRef.current = getNextPageParam(result.data);
if (resetRef.current) {
resetRef.current = false;
setPages(select(result.data));
} else {
setPages((pages) =>
inverseAppend
? select(result.data).concat(pages ?? [])
: (pages ?? []).concat(select(result.data)),
);
}
}, [result.data, inverseAppend, getNextPageParam, select, result.isSuccess]);
return {
...result,
data: pages,
isLoading: result.isFetching && pages === undefined,
hasMore: nextPageRef.current !== undefined,
next,
};
}
This example works great with pagination, but the problem when I'm trying to add new elements. I can't distinguish are new elements arrived from new pagination batch or when I called mutation and invalidated tag (in this case RTK refetch current subscriptions and update result.data which I'm listening in second useEffect).
I need to somehow to identify when I need to append new arrived data (in case of next pagination) or replace fully (in case when mutation was called and needs to reset cursor and scroll to top/bottom).
My calling mutation and fetching the data placed in different components. I tried to use fixedCacheKey to listening when mutation was called and needs to reset data, but I very quickly faced with a lot of duplication and problem when I need to reuse mutation in different places, but keep the same fixed cache key.
Someone has an idea how to accomplish it? Perhaps, I need to take another direction with useInfiniteQuery implementation or provide some fixes to current implementation. But I have no idea to handle this situation. Thanks!

Cannot invoke an object which is possibly 'undefined'. ReactTypescript by passing state setter as props

I am passing a state prop from parent to child:
Parent:
const [refresh, setRefresh] = React.useState<boolean>(true);
passing into Component as
<Chart setRefresh={setRefresh}/>
Child
interface IChart {
refreshChart?: boolean;
setRefresh?: (newRefresh: boolean) => void;
}
const Chart: React.FunctionComponent<IChart> = ({refreshChart, setRefresh}) => {
const handleRefresh = () => {
setRefresh(false);
}
}
Obviously it's to do with the type in the IChart interface as if I use any or not make it optional it works, but I need it optional and want to avoid any.
Obviously it's to do with the type in the IChart interface as if I use any or not make it optional it works, but I need it optional and want to avoid any.
If you want to keep the property optional, then you have to deal with the fact it's optional, either by making the call to it check first:
const Chart: React.FunctionComponent<IChart> = ({refreshChart, setRefresh}) => {
const handleRefresh = () => {
if (setRefresh) { // ***
setRefresh(false);
}
};
}
...or by having a default:
const Chart: React.FunctionComponent<IChart> = ({refreshChart, setRefresh = (b: boolean) => {}}) => {
// ^^^^^^^^^^^^^^^^^^^^^
const handleRefresh = () => {
setRefresh(false);
};
}
You might even make creating handleRefresh conditional based on whether setRefresh exists, and possibly even change what the component renders (e.g., no refresh UI) if setRefresh isn't provided.
it's because in IChart interface you're saying that this property is optional, so it is "possibly undefined". Just remove "?" from setRefresh if the value is required.
I do with Similar cases a simple if condition basically because if tweak it a teammate may come in the future and integrate it wrongly, or add a default value to it
const handleRefresh = () => {
if(setRefresh)
setRefresh(false);
}

How to use useEffect correctly with useContext as a dependency

I'm working on my first React project and I have the following problem.
How I want my code to work:
I add Items into an array accessible by context (context.items)
I want to run a useEffect function in a component, where the context.items are displayed, whenever the value changes
What I tried:
Listing the context (both context and context.items) as a dependency in the useEffect
this resulted in the component not updating when the values changed
Listing the context.items.length
this resulted in the component updating when the length of the array changed however, not when the values of individual items changed.
wraping the context in Object.values(context)
result was exactly what I wanted, except React is now Complaining that *The final argument passed to useEffect changed size between renders. The order and size of this array must remain constant. *
Do you know any way to fix this React warning or a different way of running useEffect on context value changing?
Well, didn't want to add code hoping it would be some simple error on my side, but even with some answers I still wasn't able to fix this, so here it is, reduced in hope of simplifying.
Context component:
const NewOrder = createContext({
orderItems: [{
itemId: "",
name: "",
amount: 0,
more:[""]
}],
addOrderItem: (newOItem: OrderItem) => {},
removeOrderItem: (oItemId: string) => {},
removeAllOrderItems: () => {},
});
export const NewOrderProvider: React.FC = (props) => {
// state
const [orderList, setOrderList] = useState<OrderItem[]>([]);
const context = {
orderItems: orderList,
addOrderItem: addOItemHandler,
removeOrderItem: removeOItemHandler,
removeAllOrderItems: removeAllOItemsHandler,
};
// handlers
function addOItemHandler(newOItem: OrderItem) {
setOrderList((prevOrderList: OrderItem[]) => {
prevOrderList.unshift(newOItem);
return prevOrderList;
});
}
function removeOItemHandler(oItemId: string) {
setOrderList((prevOrderList: OrderItem[]) => {
const itemToDeleteIndex = prevOrderList.findIndex((item: OrderItem) => item.itemId === oItemId);
console.log(itemToDeleteIndex);
prevOrderList.splice(itemToDeleteIndex, 1);
return prevOrderList;
});
}
function removeAllOItemsHandler() {
setOrderList([]);
}
return <NewOrder.Provider value={context}>{props.children}</NewOrder.Provider>;
};
export default NewOrder;
the component (a modal actually) displaying the data:
const OrderMenu: React.FC<{ isOpen: boolean; hideModal: Function }> = (
props
) => {
const NewOrderContext = useContext(NewOrder);
useEffect(() => {
if (NewOrderContext.orderItems.length > 0) {
const oItems: JSX.Element[] = [];
NewOrderContext.orderItems.forEach((item) => {
const fullItem = {
itemId:item.itemId,
name: item.name,
amount: item.amount,
more: item.more,
};
oItems.push(
<OItem item={fullItem} editItem={() => editItem(item.itemId)} key={item.itemId} />
);
});
setContent(<div>{oItems}</div>);
} else {
exit();
}
}, [NewOrderContext.orderItems.length, props.isOpen]);
some comments to the code:
it's actually done in Type Script, that involves some extra syntax
-content (and set Content)is a state which is then part of return value so some parts can be set dynamically
-exit is a function closing the modal, also why props.is Open is included
with this .length extension the modal displays changes when i remove an item from the list, however, not when I modify it not changeing the length of the orderItems,but only values of one of the objects inside of it.
as i mentioned before, i found some answers where they say i should set the dependency like this: ...Object.values(<contextVariable>) which technically works, but results in react complaining that *The final argument passed to useEffect changed size between renders. The order and size of this array must remain constant. *
the values displayed change to correct values when i close and reopen the modal, changing props.isOpen indicating that the problem lies in the context dependency
You can start by creating your app context as below, I will be using an example of a shopping cart
import * as React from "react"
const AppContext = React.createContext({
cart:[]
});
const AppContextProvider = (props) => {
const [cart,setCart] = React.useState([])
const addCartItem = (newItem)=>{
let updatedCart = [...cart];
updatedCart.push(newItem)
setCart(updatedCart)
}
return <AppContext.Provider value={{
cart
}}>{props.children}</AppContext.Provider>;
};
const useAppContext = () => React.useContext(AppContext);
export { AppContextProvider, useAppContext };
Then you consume the app context anywhere in the app as below, whenever the length of the cart changes you be notified in the shopping cart
import * as React from "react";
import { useAppContext } from "../../context/app,context";
const ShoppingCart: React.FC = () => {
const appContext = useAppContext();
React.useEffect(() => {
console.log(appContext.cart.length);
}, [appContext.cart]);
return <div>{appContext.cart.length}</div>;
};
export default ShoppingCart;
You can try passing the context variable to useEffect dependency array and inside useEffect body perform a check to see if the value is not null for example.

Limit renders of component that uses useContext via a useFooController/useFooHook

I'm using custom hooks for a component, and the custom hook uses a custom context. Consider
/* assume FooContext has { state: FooState, dispatch: () => any } */
const useFoo = () => {
const { state, dispatch } = useContext(FooContextContext)
return {apiCallable : () => apiCall(state) }
}
const Foo = () => {
const { apiCallable } = useFoo()
return (
<Button onClick={apiCallable}/>
)
}
Lots of components will be making changes to FooState from other components (form inputs, etc.). It looks to me like Foo uses useFoo, which uses state from FooStateContext. Does this mean every change to FooContext will re-render the Foo component? It only needs to make use of state when someone clicks the button but never otherwise. Seems wasteful.
I was thinking useCallback is specifically for this, so I am thinking return {apiCallable : useCallback(() => apiCall(state)) } but then I need to add [state] as a second param of useCallback. Then that means the callback will be re-rendered whenever state updates, so I'm back at the same issue, right?
This is my first time doing custom hooks like this. Having real difficulty understanding useCallback. How do I accomplish what I want?
Edit Put another way, I have lots of components that will dispatch small changes to deeply nested properties of this state, but this particular component must send the entire state object via a RESTful API, but otherwise will never use the state. It's irrelevant for rendering this component completely. I want to make it so this component never renders even when I'm making changes constantly to the state via keypresses on inputs (for example).
Since you provided Typescript types in your question, I will use them in my response.
Way One: Split Your Context
Given a context of the following type:
type ItemContext = {
items: Item[];
addItem: (item: Item) => void;
removeItem: (index: number) => void;
}
You could split the context into two separate contexts with the following types:
type ItemContext = Item[];
type ItemActionContext = {
addItem: (item: Item) => void;
removeItem: (index: number) => void;
}
The providing component would then handle the interaction between these two contexts:
const ItemContextProvider = () => {
const [items, setItems] = useState([]);
const actions = useMemo(() => {
return {
addItem: (item: Item) => {
setItems(currentItems => [...currentItems, item]);
},
removeItem: (index: number) => {
setItems(currentItems => currentItems.filter((item, i) => index === i));
}
};
}, [setItems]);
return (
<ItemActionContext.Provider value={actions}>
<ItemContext.Provider value={items}>
{children}
</ItemContext.Provider>
</ItemActionContext.Provider>
)
};
This would allow you to get access to two different contexts that are part of one larger combined context.
The base ItemContext would update as items are added and removed causing rerenders for anything that was consuming it.
The assoicated ItemActionContext would never update (setState functions do not change for their lifetime) and would never directly cause a rerender for a consuming component.
Way Two: Some Version of an Subscription Based Value
If you make the value of your context never change (mutate instead of replace, HAS THE WORLD GONE CRAZY?!) you can set up a simple object that holds the data you need access to and minimises rerenders, kind of like a poor mans Redux (maybe it's just time to use Redux?).
If you make a class similar to the following:
type Subscription<T> = (val: T) => void;
type Unsubscribe = () => void;
class SubscribableValue<T> {
private subscriptions: Subscription<T>[] = [];
private value: T;
constructor(val: T) {
this.value = val;
this.get = this.get.bind(this);
this.set = this.set.bind(this);
this.subscribe = this.subscribe.bind(this);
}
public get(): T {
return this._val;
}
public set(val: T) {
if (this.value !== val) {
this.value = val;
this.subscriptions.forEach(s => {
s(val)
});
}
}
public subscribe(subscription: Subscription<T>): Unsubscriber {
this.subscriptions.push(subscription);
return () => {
this.subscriptions = this.subscriptions.filter(s => s !== subscription);
};
}
}
A context of the following type could then be created:
type ItemContext = SubscribableValue<Item[]>;
The providing component would look something similar to:
const ItemContextProvider = () => {
const subscribableValue = useMemo(() => new SubscribableValue<Item[]>([]), []);
return (
<ItemContext.Provider value={subscribableValue}>
{children}
</ItemContext.Provider>
)
};
You could then use some a custom hooks to access the value as needed:
// Get access to actions to add or remove an item.
const useItemContextActions = () => {
const subscribableValue = useContext(ItemContext);
const addItem = (item: Item) => subscribableValue.set([...subscribableValue.get(), item]);
const removeItem = (index: number) => subscribableValue.set(subscribableValue.get().filter((item, i) => i === index));
return {
addItem,
removeItem
}
}
type Selector = (items: Item[]) => any;
// get access to data stored in the subscribable value.
// can provide a selector which will check if the value has change each "set"
// action before updating the state.
const useItemContextValue = (selector: Selector) => {
const subscribableValue = useContext(ItemContext);
const selectorRef = useRef(selector ?? (items: Item[]) => items)
const [value, setValue] = useState(selectorRef.current(subscribableValue.get()));
const useEffect(() => {
const unsubscribe = subscribableValue.subscribe(items => {
const newValue = selectorRef.current(items);
if (newValue !== value) {
setValue(newValue);
}
})
return () => {
unsubscribe();
};
}, [value, selectorRef, setValue]);
return value;
}
This would allow you to reduce rerenders using selector functions (like an extremely basic version of React Redux's useSelector) as the subscribable value (root object) would never change reference for its lifetime.
The downside of this is that you have to manage the subscriptions and always use the set function to update the held value to ensure that the subscriptions will be notified.
Conclusion:
There are probably a number of other ways that different people would attack this problem and you will have to find one that suits your exact issue.
There are third party libraries (like Redux) that could also help you with this if your context / state requirements have a larger scope.
Does this mean every change to FooContext will re-render the Foo component?
Currently (v17), there is no bailout for Context API. Check my another answer for examples. So yes, it will always rerender on context change.
It only needs to make use of state when someone clicks the button but never otherwise. Seems wasteful.
Can be fixed by splitting context providers, see the same answer above for explanation.

Resources