React - Error: Maximum update depth exceeded - useReducer - reactjs

I'm facing an error:
react-dom.development.js:23093 Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
I understand that the problem may be due to the fact that I'm calling the checkError and validationPassed functions that modify the state through the useReducer, within the checkValidations function that is called through the useEffect hook, but I don't know how to solve it
The code is as:
interface ValidationField {
errorMessage?: string;
focused: boolean;
hasError: boolean;
}
interface ClientEditorState {
client: Client;
validations: { [key in keyof Client]: ValidationField };
}
enum clientEditorActions {
UPDATE_ENTITY = 'CLIENT_EDITOR/UPDATE_ENTITY',
UPDATE_FOCUSED = 'CLIENT_EDITOR/UPDATE_FOCUSED',
VALIDATION_ERROR = 'CLIENT_EDITOR/VALIDATION_ERROR',
VALIDATION_PASSED = 'CLIENT_EDITOR/VALIDATION_PASSED',
}
interface UpdateEntityAction extends Action<typeof clientEditorActions.UPDATE_ENTITY> {
name: string;
value: string | boolean;
}
interface UpdateFocusedAction extends Action<typeof clientEditorActions.UPDATE_FOCUSED> {
name: string;
}
interface ValidationErrorAction extends Action<typeof clientEditorActions.VALIDATION_ERROR> {
message: string;
name: string;
}
interface ValidationPassedAction extends Action<typeof clientEditorActions.VALIDATION_PASSED> {
name: string;
}
type ClientEditorActions = UpdateEntityAction | UpdateFocusedAction | ValidationErrorAction | ValidationPassedAction;
const clientReducer: Reducer<ClientEditorState, ClientEditorActions> = (prevState, action) => {
switch (action.type) {
case clientEditorActions.UPDATE_ENTITY:
const clientUpdated = _cloneDeep(prevState || ({} as Client));
_set(clientUpdated, `client.${action.name}`, action.value);
return clientUpdated;
case clientEditorActions.UPDATE_FOCUSED:
const validationField = _cloneDeep(prevState);
_set(validationField, `validations.${action.name}.focused`, true);
return validationField;
case clientEditorActions.VALIDATION_ERROR:
const errorField = _cloneDeep(prevState);
_set(errorField, `validations.${action.name}.hasError`, true);
_set(errorField, `validations.${action.name}.errorMessage`, action.message);
return errorField;
case clientEditorActions.VALIDATION_PASSED:
const passed = _cloneDeep(prevState);
_set(passed, `validations.${action.name}.hasError`, false);
_set(passed, `validations.${action.name}.errorMessage`, undefined);
return passed;
default:
return prevState;
}
};
...
const getInitialState = (): ClientEditorState => ({
client: entity as Client,
validations: {
firstName: {
focused: false,
hasError: false,
},
},
});
const [state, clientDispatch] = useReducer(clientReducer, getInitialState());
const checkError = useCallback((name: string, message: string) => {
clientDispatch({
type: clientEditorActions.VALIDATION_ERROR,
name,
message,
});
}, []);
const validationPassed = useCallback((name: string) => {
clientDispatch({
type: clientEditorActions.VALIDATION_PASSED,
name,
});
}, []);
const checkValidations = useCallback(
(c: Client) => {
let validation = false;
const { firstName } = state.validations;
if (!c.firstName && firstName.focused) {
validation = false;
checkError('firstName', f('client.requiredFieldClient'));
} else {
validation = true;
validationPassed('firstName');
}
},
[checkError, f, state.validations, validationPassed],
);
const [clientUpdateHandler] = useDebouncedCallback((clientUpdated: Client) => {
dispatch(updateEntityEditor(clientUpdated));
}, 800);
useEffect(() => {
if (!_isEqual(state.client, entity)) {
clientUpdateHandler(state.client as Client);
}
const { firstName } = state.validations;
if (firstName.focused) checkValidations(state.client);
}, [checkValidations, clientUpdateHandler, entity, state.client, state.validations]);

I understand that the problem may be due to the fact that I'm calling the checkError and validationPassed functions that modify the state through the useReducer, within the checkValidations function that is called through the useEffect hook
Yeah, exactly. Try to reduce your dependencies in the useEffect and the associated functions in useCallback. Any one of these changing will cause the useEffect to rerun.
Back to the question, your useEffect currently depends on state.validations. So whenever state.validations changes the useEffect will rerun. Consider doing
const { firstName } = state.validations;
outside the useEffect and the checkValidations callback. This will stop it from rerunning every time state.validations changes.

Related

Trouble enforcing a function's parameter interface when using React.useContext()

I have only been using TypeScript a couple months, and I just noticed that the compiler is not enforcing the shape of data a function accepts if that function is accessed through React.useContext().
This setup here is not exactly what I have going on, but it more or less shows the problem I am trying to figure out.
import * as React from 'react';
//==>> Reducer State Interface
interface InitialStateInterface {
handleSettingFooBar: Function;
foo: string | null;
bar: boolean;
}
//==>> Function Interface
interface PayloadInterface {
foo?: string | null;
bar?: boolean;
}
//==> Reducer
interface ReducerInterface {
type: string;
payload: PayloadInterface;
}
enum ActionTypes {
SET_FOO = 'SET_FOO',
SET_BAR = 'SET_BAR',
}
const initialState: InitialStateInterface = {
handleSettingFooBar: () => null,
foo: null,
bar: false,
};
const SomeContext = React.createContext<InitialStateInterface>(initialState);
const ContextReducer = (state: any, { type, payload }: ReducerInterface) => {
switch (type) {
case ActionTypes.SET_FOO:
return { ...state, foo: payload.foo };
case ActionTypes.SET_BAR:
return { ...state, bar: payload.bar };
default:
return state;
}
};
const SomeProvider = () => {
const [state, dispatch] = React.useReducer(ContextReducer, initialState);
function handleSettingFooBar(data: PayloadInterface) {
let { foo, bar } = data;
if (foo) dispatch({ type: ActionTypes.SET_FOO, payload: { foo } });
if (bar) dispatch({ type: ActionTypes.SET_BAR, payload: { bar } });
}
/** Okay, of course, no error */
handleSettingFooBar({ foo: 'test' });
/** Error as expected, type does not match */
handleSettingFooBar({ foo: false });
/** Error as expected, key does not exist */
handleSettingFooBar({ randomKey: 'cant do this' });
return <SomeContext.Provider value={{ ...state, handleSettingFooBar }} />;
};
/* ===> But when writing a component that uses that context <=== */
export const SomeComponent = () => {
const { handleSettingFooBar } = React.useContext(SomeContext);
/** Why is the compiler not yelling at me here??? */
handleSettingFooBar({ randomKey: 'hahaha' });
};
export { SomeProvider, SomeContext };
I have tried putting the interface in when calling the context, like this:
const { handleSettingFooBar } = React.useContext<InitialStateInterface>(SomeContext);
But that made no difference.
I am expecting that if somebody is authoring a component that uses this context and its provided functions, that it will regulate the data (at compile time, of course) they try to pass in so a generic setter may not add a value that does not belong in the context reducer state.
Please help, thanks!
The SomeContext has the InitialStateInterface type which defines handleSettingFooBar as handleSettingFooBar: Function, and it does not know how you actually implemented it.
You can change that to handleSettingFooBar: (data:PayloadInterface) => void and then the typescript would know what kind of input should be allowed for it.

type 'undefined' is not assignable to type, losing type in map()

I have an action in my redux toolkit that's attempting to set some state. Relevant code below:
interfaces
export interface ProposalTag {
id: number;
name: string;
hex: string;
color: string;
}
export interface ProposalSelectedTag {
proposal_id: number;
proposal_tag_id: number;
}
redux
import { ProposalTag, ProposalSelectedTag } from '../../types/proposalTags';
interface ProposalTagsSlice {
proposalTags: ProposalTag[];
selectedProposalTags: ProposalTag[];
}
const initialState: ProposalTagsSlice = {
proposalTags: [],
selectedProposalTags: [],
};
const matchTags = (
selectedTags: ProposalSelectedTag[],
proposalTags: ProposalTag[],
): ProposalTag[] => {
const tags = selectedTags.map((selectedTag: ProposalSelectedTag) => {
return proposalTags.find(proposalTag => proposalTag.id === selectedTag.proposal_tag_id);
});
return tags ?? [];
};
export const proposalTagsSlice = createSlice({
name: 'proposalTags',
initialState,
reducers: {
setSelectedProposalTags: (state, action: PayloadAction<ProposalSelectedTag[]>) => {
if (state.proposalTags === undefined) return;
state.selectedProposalTags =
action.payload === null ? [] : matchTags(action.payload, state.proposalTags);
},
},
});
The goal of matchTags is to convert the payload of ProposalSelectedTag[] to ProposalTag[]. So in theory, ProposalSelectedTag.proposal_tag_id
The type errors I get back are the following:
Did I lose typing somewhere in matchTags?
That's because Array.prototype.find will return undefined if the element is not found.
If you are sure that the item exists in the list, you can make an assertion that will calm TypeScript down.
const tags = selectedTags.map((selectedTag: ProposalSelectedTag) => {
const item = proposalTags.find(proposalTag => proposalTag.id === selectedTag.proposal_tag_id);
if (!item) throw new Error('item was not found')
return item
});
You can also use !
const tags = selectedTags.map((selectedTag: ProposalSelectedTag) => {
return proposalTags.find(proposalTag => proposalTag.id === selectedTag.proposal_tag_id)!;
});
Or you can set a default value
const tags = selectedTags.map((selectedTag: ProposalSelectedTag) => {
return proposalTags.find(proposalTag => proposalTag.id === selectedTag.proposal_tag_id) ?? 10;
});

How to access the updated value in my store immediately after updating it?

I have a React application that is currently using Redux for state management.
What I am trying to achieve: Click a Buy Now button - dispatch a action that makes a request to the server to add the item (increment the cart item count based on server response), check the state to see if the cart item count is greater than 0 & do something if it is.
For some reason, I have to click the button twice in order for the cartItemCount to reflect 1?
My current implementation looks like the below (I have tried to pull out all the unrelated code due to the file being quite large):
CourseSpecificScreen.tsx
const mapStateToProps = (state: RootState) => {
return {
courseSpecificReducer: state.courseSpecificReducer,
authState: state.authReducer,
currencyState: state.currencyReducer,
cartReducer: state.cartReducer,
courseCategoriesState: state.courseCategoriesReducer,
};
};
const mapDispatchTopProps = (dispatch: Dispatch<AnyAction>) => {
return bindActionCreators(ActionCreators, dispatch);
};
const connector = connect(mapStateToProps, mapDispatchTopProps);
type CourseSpecificScreenNavigationProp = CompositeNavigationProp<
StackNavigationProp<ExploreRouteStackParamList, "CourseSpecificScreen">,
CompositeNavigationProp<
StackNavigationProp<AppRouteHeaderParamList>,
StackNavigationProp<AuthRouteStackParamList>
>
>;
type CourseSpecificScreenRouteProp = RouteProp<
ExploreRouteStackParamList,
"CourseSpecificScreen"
>;
type Props = PropsFromRedux & {
navigation: CourseSpecificScreenNavigationProp;
route: CourseSpecificScreenRouteProp;
};
type State = {
cartItemCount: number;
};
class CourseSpecificScreen extends Component<Props, State> {
pruchaseItem = async () => {
const {
courseSpecificReducer,
clearCartAndAddItem,
navigation,
cartReducer,
getCartItemCount,
} = this.props;
const paymentMethod = paymentMethodForDevice();
await clearCartAndAddItem(
paymentMethod,
courseSpecificReducer.productData.code as string,
1,
navigation
)
if(cartReducer.cartItemCount > 0) {
// do some stuff
}
};
render() {
return (
<Button
btnStyle={[this.getStyles().smallButtonBuyCourse]}
labelStyle={[this.getStyles().buttonStickyLabelStyle]}
label={translate(
productData.isBundle && productData.isBundle === true
? "CategorySpecificScreen_buyThisBundle"
: "CategorySpecificScreen_buyThisCourse",
)}
onPress={this.purchaseItem}
disabled={false}
/>
)
};
CourseSpecificScreen.contextType = LocalizationContext;
export default connector(CourseSpecificScreen);
ThunkActions.ts
export const clearCartAndAddItem = (
paymentMethod: string,
productCode: string,
quantity: number,
navigation: any,
): AppThunk => {
return async (dispatch) => {
dispatch(cartActions.updateCartLoadingStatus(true));
const response = await cartServices.clearCart();
const {httpStatusCode} = response as APIResponse;
switch (httpStatusCode) {
case httpStatusCodes.SUCCESS_OK:
case httpStatusCodes.SUCCESS_CREATED:
case httpStatusCodes.SUCCESS_NO_CONTENT:
dispatch(cartActions.updateCartLoadingStatus(false));
dispatch(cartActions.updateCartItemCount(0))
globalConfig.setCartItemCount(0);
dispatch(addItemToCart(paymentMethod, productCode, quantity, navigation));
break;
case httpStatusCodes.CLIENT_ERROR_UNAUTHORIZED:
case httpStatusCodes.SERVER_ERROR_INTERNAL_SERVER_ERROR:
dispatch(cartActions.updateCartLoadingStatus(false));
let alertMessage = "Error, please try again later.";
if (response?.message) alertMessage = response?.message;
Alert.alert("Alert", alertMessage, [
{
text: "Ok",
},
]);
break;
default: {
dispatch(cartActions.updateCartLoadingStatus(false));
}
}
};
};
export const addItemToCart = (
paymentMethod: string,
productCode: string,
quantity: number,
navigation: any,
): AppThunk => {
return async (dispatch) => {
dispatch(cartActions.updateCartLoadingStatus(true));
const response = await cartServices.addItemToCart(productCode, quantity, paymentMethod);
const {httpStatusCode, data, error, message} = response as APIResponse;
console.log('add_item_to_cart_response:', response);
switch (httpStatusCode) {
case httpStatusCodes.SUCCESS_OK:
case httpStatusCodes.SUCCESS_CREATED:
dispatch(cartActions.updateCartLoadingStatus(false));
dispatch(cartActions.updateCartItemCount(quantity));
globalConfig.setCartItemCount(quantity);
break;
case httpStatusCodes.CLIENT_ERROR_UNAUTHORIZED:
dispatch(cartActions.updateCartLoadingStatus(false));
break;
case httpStatusCodes.SERVER_ERROR_INTERNAL_SERVER_ERROR:
case httpStatusCodes.CLIENT_ERROR_BAD_REQUEST:
dispatch(cartActions.updateCartLoadingStatus(false));
Alert.alert("Alert", (message)? message : "Error, it looks like you already have access to this course.", [
{
text: "Ok",
},
]);
break;
default: {
dispatch(cartActions.updateCartLoadingStatus(false));
}
}
};
};
Reducers.ts
const initialState: CartInitialState = {
isLoading: true,
cartToken: "",
responseStatus: apiResponseStatuses.IDLE,
cartItemCount: 0,
isMessageVisible: false,
message: "",
};
export default function cartReducer(
state = initialState,
action: CartActionTypes,
): CartInitialState {
switch (action.type) {
case UPDATE_LOADING_STATUS:
return {
...state,
isLoading: action.isLoading,
};
case UPDATE_CART_TOKEN:
return {
...state,
cartToken: action.cartToken,
};
case UPDATE_RESPONSE_STATUS:
return {
...state,
responseStatus: action.responseStatus,
};
case UPDATE_CART_ITEM_COUNT_TOKEN:
return {
...state,
cartItemCount: action.cartItemCount,
};
case CLEAR_DATA_ON_LOGOUT:
return {
...state,
isLoading: true,
cartToken: "",
responseStatus: apiResponseStatuses.IDLE,
cartItemCount: 0,
isMessageVisible: false,
message: "",
};
default: {
return state;
}
}
}
In the pruchaseItem() function of CourseSpecificScreen.tsx, I would like to dispatch a action that adds the item to the cart and immediately afterwards check if the cartItemCount has been updated & if it has, do something... This functionality works as expected, but only after clicking the Buy Now button twice.
I have ruled out the possibility of the issue being the API request failing the first time.
I have been stuck on this issue for several days now so any help or advice would be greatly appreciated. Let me know if I need to include more information
In my case, I was storing a reference of the old cartReducer state before it was being updated.
I got this working by updating my purchaseItem() function to look like the below:
pruchaseItem = async () => {
const {
courseSpecificReducer,
clearCartAndAddItem,
navigation
} = this.props;
const paymentMethod = paymentMethodForDevice();
await clearCartAndAddItem(
paymentMethod,
courseSpecificReducer.productData.code as string,
1,
navigation
)
const { cartReducer } = this.props;
if(cartReducer.cartItemCount > 0) {
// do some stuff
}
};

Too many re-renders with useSelector hook closure

Considering this state, I need to select some data from it:
const initialState: PlacesStateT = {
activeTicket: null,
routes: {
departure: {
carriageType: 'idle',
extras: {
wifi_price: 0,
linens_price: 0,
},
},
arrival: {
carriageType: 'idle',
extras: {
wifi_price: 0,
linens_price: 0,
},
},
},
};
so, I came up with two approaches:
first:
const useCoaches = (dir: string) => {
const name = mapDirToRoot(dir);
const carType = useAppSelector((state) => state.places.routes[name].carriageType);
const infoT = useAppSelector((state) => {
return state.places.activeTicket.trainsInfo.find((info) => {
return info.routeName === name;
});
});
const coaches = infoT.trainInfo.seatsTrainInfo.filter((coach) => {
return coach.coach.class_type === carType;
});
return coaches;
};
and second:
const handlerActiveCoaches = (name: string) => (state: RootState) => {
const { carriageType } = state.places.routes[name];
const { activeTicket } = state.places;
const trainInfo = activeTicket.trainsInfo.find((info) => {
return info.routeName === name;
});
return trainInfo.trainInfo.seatsTrainInfo.filter((coach) => {
return coach.coach.class_type === carriageType;
});
};
const useActiveInfo = (dir: string) => {
const routeName = mapDirToRoot(dir);
const selectActiveCoaches = handlerActiveCoaches(routeName);
const coaches = useAppSelector(selectActiveCoaches);
return coaches;
};
Eventually, if the first one works ok then the second one gives a lot of useless re-renders in component. I suspect that there are problems with selectActiveCoaches closure, maybe react considers that this selector is different on every re-render but I am wrong maybe. Could you explain how does it work?
selectActiveCoaches finishes with return seatsTrainInfo.filter(). This always returns a new array reference, and useSelector will force your component to re-render whenever your selector returns a different reference than last time. So, you are forcing your component to re-render after every dispatched action:
https://react-redux.js.org/api/hooks#equality-comparisons-and-updates
One option here would be to rewrite this as a memoized selector with Reselect:
https://redux.js.org/usage/deriving-data-selectors

ContextApi, useReducer, & Typescript - calculated value is not accessible on component

Apologies for the somewhat opaque title, but I am having difficulties being more precise here.
So I have a Context/Reducer Logic, where I initialise the context with some values. I then have a reducer Logic on a custom Provider and use useMemo to calculate values. When trying to access one on of those values (that isn't in the state/initialState) on a component typescript gets angry at me and tells me that said value does not exist on State. What is the best way to remedy this warning?
I have the following definition of a Context/Reducer.
interface State {
displaySidebar: boolean
}
const initialState = {
displaySidebar: false
}
type Action =
| {
type: 'OPEN_SIDEBAR'
}
| {
type: 'CLOSE_SIDEBAR'
}
const UIContext = React.createContext<State>(initialState)
UIContext.displayName = 'UIContext'
const uiReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'OPEN_SIDEBAR': {
return {
...state,
displaySidebar: true,
}
}
case 'CLOSE_SIDEBAR': {
return {
...state,
displaySidebar: false,
}
}
}
}
const UIProvider: FC = (props) => {
const [state, dispatch] = React.useReducer(uiReducer, initialState)
const openSidebar = (): void => dispatch({ type: 'OPEN_SIDEBAR' })
const closeSidebar = (): void => dispatch({ type: 'CLOSE_SIDEBAR' })
const value = useMemo(
() => ({
...state,
openSidebar,
closeSidebar,
}),
[state]
)
return <UIContext.Provider value={value} {...props} />
}
export const useUI = () => {
const context = React.useContext(UIContext)
if (context === undefined) {
throw new Error(`useUI must be used within a UIProvider`)
}
return context
}
export const ManagedUIContext: FC = ({ children }) => (
<UIProvider>
<ThemeProvider>{children}</ThemeProvider>
</UIProvider>
)
now when I try to use const {closeSidebar} = useUI() in a component typescript gets angry with me and tells me that Property 'closeSidebar' does not exist on type 'State'. I get that, but I was not able to figure out how to properly add closeSidebar to the React.Context type.
When you create context you tell TS that its type will be State, so it doesn't expect anything else to be there. If you want to add additional fields you can create an intersection type, state + methods, either as a named type of just React.createContext<State & {openSidebar : ()=> void, closeSidebar: ()=> void}>. Note that as your initial state doesn't have methods you either need to make them optional or provide some sort of dummy versions.

Resources