Can't update Zustand state using setState function - reactjs

In Comp1 when i hover mouse on it i want the state to change to true (I'm passing true param to it). Also i want to ensure that by doing so this will not cause the Comp2 component to re-render.
My understanding was that if i do like so useStoreOnHover.setState({ onComp1: true }) it should work but it does not :(
I have also tried with const onComp1Set = useStoreOnHover((s) => s.onComp1Set) but still same :(
The only way i was able to get it working is by const { onComp1Set } = useStoreOnHover() but I'm trying to avoid these type of de-structuring because it also triggers re-renders to other components.
Live example: https://codesandbox.io/s/winter-grass-qxrv8
import create, { GetState, SetState } from "zustand";
type typeStoreOnHover = {
onComp1: boolean;
onComp1Set: (val: boolean) => void;
onComp2: boolean;
};
export const useStoreOnHover = create<typeStoreOnHover>(
(set: SetState<typeStoreOnHover>, get: GetState<typeStoreOnHover>) => {
return {
onComp1: false,
onComp1Set: (val) => set({ onComp1: val }),
onComp2: false
};
}
);
const Comp1 = () => {
const onComp1 = useStoreOnHover.getState().onComp1;
// const onComp1Set = useStoreOnHover((s) => s.onComp1Set);
console.log("Comp 1", onComp1);
return (
<div
onMouseEnter={() => {
// onComp1Set(true);
useStoreOnHover.setState({ onComp1: true });
}}
>
Comp 1 {onComp1 ? "True" : "False"}
</div>
);
};
const Comp2 = () => {
const onComp2 = useStoreOnHover((s) => s.onComp2);
console.log("Comp 2", onComp2);
return <div>Comp 2 </div>;
};
export default function App() {
return (
<>
<Comp1 />
<Comp2 />
</>
);
}

According to zustand documentation, this should be the actual approach,
const onComp1 = useStoreOnHover((s) => s.onComp1);
I have tested it on your CodeSandBox link and it worked.
I am not familiar with the zustand library but this might help you.

Related

Creating an object with hooks as property

Description:
I am using Radix-UI and I want to create a service for creating toasts:
https://www.radix-ui.com/docs/primitives/components/toast
Problem:
I am unable to change the statte of the open-hook inside the object
How it works:
Any random component from anywhere in the app can add toasts to the store
The Toastr.tsx watches the store toasts
whenever a new object/ToastrItem is added it renders the toast
After 5 seconds (default value) onOpenChangeis called to set open=false through a hook. This does not work
Toastr.tsx
const Toastr = () => {
const { toasts, setToasts } = useToastrStore();
const eventDateRef = React.useRef(new Date());
const timerRef = React.useRef(0);
React.useEffect(() => {
console.log(toasts);
return () => clearTimeout(timerRef.current);
}, []);
React.useEffect(() => {
console.log('TOASTS:');
console.log(toasts);
}, [toasts]);
return (
<ToastProvider swipeDirection='right'>
{toasts &&
toasts.length > 0 &&
isArray(toasts) &&
toasts.map((toast: ToastrItem, idx: number) => {
return (
<Toast
open={toast.open}
onOpenChange={(openStatus: boolean) => {
//This does not work
toast.setOpen(openStatus)
}}
key={idx}
>
<ToastTitle>
{toast.title}
<h1>{toast.open}</h1>
</ToastTitle>
<ToastDescription asChild>
<time dateTime={eventDateRef.current.toISOString()}>{'TEEEEEEEEEEEEEEEEEEEEEEEEEEXT'}</time>
</ToastDescription>
<ToastAction asChild altText='Goto schedule to undo'>
<Button variant='green' size='small'>
Close
</Button>
</ToastAction>
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
};
The piece of code that does not work is:
onOpenChange={(openStatus: boolean) => {
//This does not work
toast.setOpen(openStatus)
}}
My theory is that it is because of the generator i am using:
getToast.tsx
export const getToast = () => {
const toast = new ToastObject() as ToastrItem;
const [open, setOpen] = useState(false);
toast.open = open;
toast.setOpen = setOpen;
return toast;
};
class ToastObject implements ToastrItem {
id: string;
title: string;
description: string;
variant: ToastrVariant;
open: boolean;
setOpen: (param: boolean) => void;
constructor() {
this.id = nanoid();
this.title = 'test';
this.description = 'description';
this.variant = ToastrVariant.GOOD;
this.open = true;
this.setOpen = () => false;
}
}
ToastrItem.tsx
export interface ToastrItem {
id: string;
title: string;
description: string;
variant: ToastrVariant;
open: boolean;
setOpen: (parameter: boolean) => any;
}
What are you using getToast()?
I think I see the problem without seeing the usage. React state is immutable and can only be changed by calling setState(). This means that if you modify a value within the current state, React doesn't know that the state has actually changed. You need to instead copy the original state, update what you need, and then call setState again.
Since you're using zustand, I'll show how you would accomplish this using that. I haven't actually tested this code, but it's the correct paradigm for what you want to do.
const useToastrStore = create((set) => ({
toasts: [],
// addToast will update the toasts state by appending the new toast
addToast: (toast) => set(state => ({toasts: [...state.toasts, toast]})),
// setOpen takes a toastIndex and an open status
setOpen: (toastIndex, openStatus) => {
set(state => ({
...state,
// map through all toasts, find the one that matches toastIndex, and change it's open status
toasts: state.toasts.map((toast, index) => index === toastIndex ? {
...toast,
open: openStatus
} : {...toast})
})
)
}
}))
const Toastr = () => {
const { toasts, addToast, setOpen } = useToastrStore();
return (
<ToastProvider swipeDirection='right'>
{toasts.map((toast: ToastrItem, idx: number) => (
<Toast
open={toast.open}
onOpenChange={(openStatus: boolean) => {
//This does not work
setOpen(idx, openStatus)
}}
key={idx}
>
</Toast>
)}
</ToastProvider>
);
};

Refactoring a React component for reuse

I'm looking for some advice in refactoring the following React component:
const Block = () => {
const {blockId} = useParams();
const {register, control, handleSubmit} = useForm();
const isNewBlock = typeof blockId === 'undefined';
const [saveBlockMutation] = useSaveBlockMutation();
const [deleteBlockMutation] = useDeleteBlockMutation();
const {data, loading, error} = useGetBlockQuery({
skip: isNewBlock,
variables: {block_id: parseInt(blockId!)}
});
const saveBlock = (input: any /* todo: type it */) => {
saveBlockMutation({variables: {input: input}})
.then(result => {
if (result.data?.saveBlock) {
// show notification
}
})
};
const deleteBlock = (blockId: number) => {
deleteBlockMutation({variables: {block_id: blockId}})
.then(result => {
if (result.data?.deleteBlock) {
// show notification
}
})
}
return (
<LoaderHandler loading={loading} error={error}>
{!loading && (
<>
<Header text={data ? "Block: " + data.block.identifier : "Add Block"}>
<Button onClick={handleSubmit(saveBlock)}>Save</Button>
{!isNewBlock && (<Button onClick={() => deleteBlock(parseInt(blockId!))}>Delete</Button>)}
</Header>
<Form data={data} register={register} control={control}/>
</>
)}
</LoaderHandler>
)
}
This currently works fine, but I'll be adding a number of other components that should behave the exact same way:
get some ID from the URL
load some data
render a form
save mutation
delete mutation
save/delete buttons
I feel that everything in that list I should be able to extract into something more generic, except for the "render a form" part.
I'm having trouble determining what that "something" is. Maybe a HOC is suitable here? I would end up with something like:
const Block = (props: WithCrudProps) => {
// we only render a form here
}
export default withCRUD(
Block,
{
deleteMutation: DeleteBlockMutation,
saveMutation: SaveBlockMutation,
getQuery: GetBlockQuery,
// etc.
}
);
But that feels like it could get messy real fast. What is the "react way" to approach this?
I think it would be hard to have a good implementation for withCRUD, because of all relations here:
// you need param name to extract it from params:
const params = useParams();
const param = params[paramName];
// then you need to convert param to query variables:
const queryVariables = makeQueryVariables(param)
// and you will need more of that for mutations
So I would recommend custom hook
interface UseCrudParams<T, Vars> {
id?: number;
initialData?: T;
onSave: (vars: Vars) => Promise<void>;
onDelete: () => Promise<void>
}
function useCrud<T, Vars>({
id,
initalData,
onSave,
onDelete,
}: UseCrudParams<T, Vars>): CrudProps { /* ... */}
// and then when you use it you adapt mutations to form
const formProps = useCrud({
id: blockId,
initialData: data,
onSave: variables => saveBlockMutation({ variables }),
onDelete: () => deleteBlockMutation({ variables: { block_id: blockId } }),
})
And create UI component for form layout:
function FormLayout({ header, loading, error, onSave, showDelete, onDelete, children }: FormLayoutProps) {
return (
<LoaderHandler loading={loading} error={error}>
{!loading && (
<>
<Header text={header}>
<Button onClick={onSave}>Save</Button>
{showDelete && (<Button onClick={onDelete}>Delete</Button>)}
</Header>
{children}
</>
)}
</LoaderHandler>
)
}

ReactJS How to fetch only if a condition is true?

I'm trying to find a proper way to use useFecthApi conditionnally.
I have the following component :
export const DetailedUser: FC = () => {
const userState = useFetchApi(() => findUser(userId))
const fetchContacts = mustFecthContacts() // implemenattion does not matter
return (
<>
<UserInformation userState={userState } />
</>
)
}
type Props = Readonly<{
userState: State<UserDetails>
}>
export const UserInformation : FC<Props> = ({ userState }) => {
}
What I would like to do, is to create a contactState defined by const contactState= useFetchApi(() => findContacts(userId)) only if fetchContacts equals true, and then pass this contactState to UserInformation.
So basically, something like :
export const DetailedUser: FC = () => {
const userState = useFetchApi(() => findUser(userId))
// fetchContacts is a boolean (implementation is not important)
const fetchContacts = mustFecthContacts()
const contactStates = fetchContacts ? useFetchApi(() => findContacts(userId)) : undefined
return (
<>
<UserInformation userState={userState} contactsState = {contactStates } />
</>
)
}
type Props = Readonly<{
userState: State<UserDetails>,
contactStates? : State<ContactDetails>
}>
export const UserInformation : FC<Props> = ({ userState, contactStates }) => {
}
I'm pretty new to react (and to frond en development) so I don't know how to achieve that properly.
Any advice ?
Thanks.
You should use useEffect hook to fetch data. And also useState to store the data locally in a component.
Something like:
// this stores the userState properly
const [userState, setUserState] = useState(() => {
return useFetchApi(() => findContacts(userId))
});
// this triggers everytime fetchContacts changes and will
// fetch new data if fetchContacts is "truthy"
useEffect(() => {
if(!!fetchContacts) {
const userData = useFetchApi(() => findContacts(userId))
setUserState(userData)
}
}, [fetchContacts])

Component rerenders with wrong redux state?

I have a very weird bug that I'm trying to understand for 1.5 days now. The problem with this bug is, that it is very hard to show it without showing around 2000 lines of code - I tried rebuilding a simple example in a codesandbox but couldn't reproduce the bug.
The bug can be easily described, though:
I have a parent component A, and a child component B. Both are connected to the same redux store and subscribed to a reducer called active. Both components print the exact same activeQuestion state property. Both components are connected to the redux store individually via connect()
I dispatch an action SET_ACTIVE_QUESTION and the components rerender (I'm not sure why each re-render happens) and component B now has the updated state from the store and component A doesn't ... and I can't seem to figure out why that is.
The real application is fairly big but there are a couple of weird things that I observed:
The bug disappears when I subscribe the parent component of A to the active state (Component A is subscribed itself).
The action to change the active question is qued before it is fired with setTimeout(() => doAction(), 0). If I remove the setTimeout the bug disappears.
Here is why I think this question is relevant even without code: How is it even possible that an action is dispatched in the redux store (the first console log is directly from the reducer) and the wrong state is displayed on a subsequent render? I'm not sure how this could even be possible unless its a closure or something.
Update (mapStateToProps) functions:
Component A (wrong state):
const mapStateToProps = (state: AppState) => ({
active: state.active,
answerList: state.answerList,
surveyNotifications: state.surveyNotifications,
activeDependencies: state.activeDependencies,
});
Component B (right state):
const mapStateToProps = (state: AppState) => ({
surveyNotifications: state.surveyNotifications,
active: state.active,
answerList: state.answerList,
activeDependencies: state.activeDependencies,
});
Update:
The state transition is triggered by component B (correct state) with this function:
const goToNextQuestionWithTransition = (
where: string,
shouldPerformValidation?: boolean
) => {
setInState(false);
setTimeout(() => {
props.goToQuestion(where, shouldPerformValidation);
}, 200);
};
Removing the setTimeout removes the bug (but I don't know why)
Update (show reducer):
export const INITIAL_SATE = {
activeQuestionUUID: '',
...
};
export default function (state = INITIAL_SATE, action) {
switch (action.type) {
case actionTypes.SET_ACTIVE_QUESTION:
console.log('Action from reducer', action)
return { ...state, activeQuestionUUID: action.payload };
...
default:
return {...state};
}
}
Update
Component A - correct state
const Survey: React.FC<IProps> = (props) => {
const {
survey,
survey: { tenantModuleSet },
} = props;
const [isComplete, setIsComplete] = React.useState(false);
const classes = useStyles();
const surveyUtils = useSurveyUtils();
console.log('Log from component A', props.active.activeQuestionUUID)
React.useEffect(() => {
const firstModule = tenantModuleSet[0];
if (firstModule) {
props.setActiveModule(firstModule.uuid);
} else {
setIsComplete(true);
}
}, []);
const orderedLists: IOrderedLists = useMemo(() => {
let orderedQuestionList: Array<string> = [];
let orderedModuleList: Array<string> = [];
tenantModuleSet.forEach((module) => {
orderedModuleList.push(module.uuid);
module.tenantQuestionSet.forEach((question) => {
orderedQuestionList.push(question.uuid);
});
});
return {
questions: orderedQuestionList,
modules: orderedModuleList,
};
}, [survey]);
const validateQuestion = (question: IQuestion) => {
...
};
const findModuleForQuestion = (questionUUID: string) => {
...
};
const { setActiveQuestion, setActiveModule, active } = props;
const { activeQuestionUUID, activeModuleUUID } = props.active;
const currentQuestionIndex = orderedLists.questions.indexOf(
activeQuestionUUID
);
const currentModuleIndex = orderedLists.modules.indexOf(activeModuleUUID);
const currentModule = props.survey.tenantModuleSet.filter(
(module) => module.uuid === active.activeModuleUUID
)[0];
if (!currentModule) return null;
const currentQuestion = currentModule.tenantQuestionSet.filter(
(question) => question.uuid === activeQuestionUUID
)[0];
const handleActiveSurveyScrollDirection = (destination: string) => {
...
};
const isQuestionLastInModule = ...
const moveToNextQuestion = (modules: string[], questions: string[]) => {
if (isQuestionLastInModule) {
if (currentModule.uuid === modules[modules.length - 1]) {
props.setActiveSurveyView("form");
} else {
setActiveQuestion("");
setActiveModule(modules[currentModuleIndex + 1]);
}
} else {
console.log('this is the move function')
setActiveQuestion(questions[currentQuestionIndex + 1]);
}
};
const goToQuestiton = (destination: string, useValidation = true) => {
....
moveToNextQuestion(modules, questions);
};
return (
<section className={classes.view}>
{isComplete ? (
<SurveyComplete />
) : (
<div className={classes.bodySection}>
<Module
// adding a key here is nessesary
// or the Module will not unmount when the module changes
key={currentModule.uuid}
module={currentModule}
survey={props.survey}
goToQuestion={goToQuestiton}
/>
</div>
)}
{!isComplete && (
<div className={classes.footerSection}>
<SurveyFooter
tenantModuleSet={props.survey.tenantModuleSet}
goToQuestion={goToQuestiton}
orderedLists={orderedLists}
/>
</div>
)}
</section>
);
};
const mapStateToProps = (state: AppState) => ({
active: state.active,
answerList: state.answerList,
surveyNotifications: state.surveyNotifications,
activeDependencies: state.activeDependencies,
});
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
removeQuestionNotification,
setActiveQuestion,
setActiveModule,
setActiveSurveyScrollDirection,
},
dispatch
);
export default connect(mapStateToProps, mapDispatchToProps)(Survey);
Component B (wrong state)
const Question: React.FC<IProps> = (props: IProps) => {
const [showSubmitButton, setShowSubmitButton] = React.useState(false);
const [inState, setInState] = React.useState(true);
const classes = useStyles();
const { question, module, goToQuestion, active } = props;
const notifications: Array<IQuestionNotification> =
props.surveyNotifications[question.uuid] || [];
const answerArr = props.answerList[question.uuid];
const dependency = props.activeDependencies.questions[question.uuid];
useEffect(() => {
/**
* Function that moves to next or previous question based on the activeSurveyScrollDirection
*/
const move =
active.activeSurveyScrollDirection === "forwards"
? () => goToQuestion("next", false)
: () => goToQuestion("prev", false); // backwards
if (!dependency) {
if (!question.isVisible) move();
} else {
const { type } = dependency;
if (type === DependencyTypeEnum.SUBTRACT) {
console.log('DEPENDENCY MOVE')
move();
}
}
}, [dependency, question, active.activeQuestionUUID]);
console.log('Log from component B', active.activeQuestionUUID)
const goToNextQuestionWithTransition = (
where: string,
shouldPerformValidation?: boolean
) => {
// props.goToQuestion(where, shouldPerformValidation);
setInState(false);
setTimeout(() => {
props.goToQuestion(where, shouldPerformValidation);
}, 200);
};
/**
* Questions that only accept one answer will auto submit
* Questions that have more than one answer will display
* complete button after one answer is passed.
*/
const doAutoComplete = () => {
if (answerArr?.length) {
if (question.maxSelect === 1) {
goToNextQuestionWithTransition("next");
}
if (question.maxSelect > 1) {
setShowSubmitButton(true);
}
}
};
useDidUpdateEffect(() => {
doAutoComplete();
}, [answerArr]);
return (
<Grid container justify="center">
<Grid item xs={11} md={8} lg={5}>
<div className={clsx(classes.question, !inState && classes.questionOut)}>
<QuestionBody
question={question}
notifications={notifications}
module={module}
answerArr={answerArr}
/>
</div>
{showSubmitButton &&
active.activeQuestionUUID === question.uuid ? (
<Button
variant="contained"
color="secondary"
onClick={() => goToNextQuestionWithTransition("next")}
>
Ok!
</Button>
) : null}
</Grid>
</Grid>
);
};
const mapStateToProps = (state: AppState) => ({
surveyNotifications: state.surveyNotifications,
active: state.active,
answerList: state.answerList,
activeDependencies: state.activeDependencies,
});
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
setActiveQuestion,
},
dispatch
);
export default connect(mapStateToProps, mapDispatchToProps)(Question);
Can you post a copy of the mapStateToProps of both component B and component A? If you are using reselect (or similar libraries), can you also post the selectors definitions?
Where are you putting the setTimeout() call?
If you are sure that there are no side effects within the mapStateToProps then it seems that you are mutating the activeQuestion property somewhere before or after the component B re-renders, assigning the old value. (Maybe you have to search for some assignement in conditions).
Also note that you can not always trust the console log, as it's value can be evaluated at later time the you call it.

How to trigger rerender on test component?

I am testing this connected component:
export class ExclusiveSelectboxesFormSectionView extends React.Component<Props> {
handleSelection = (field: Field, message: string) => {
this.props.setCard({ message, field });
};
cancelSelection = () => this.props.setCard({ message: null, field: null });
render() {
return (
<View>
{this.props.cancelTitle && (
<CheckBox
title={this.props.cancelTitle}
checked={this.props.card.field == null}
onPress={this.cancelSelection.bind(this)}
checkedIcon="dot-circle-o"
uncheckedIcon="circle-o"
/>
)}
{this.props.fields.map((field, i) => {
const props = {};
props.isSelected = this.props.card.field == field;
props.selectionHandler = this.handleSelection.bind(this);
return <ExclusiveFieldView field={field} key={i} {...props} />;
})}
</View>
);
}
}
const mapState = ({ currentFormReducer }) => {
const card = currentFormReducer.card || { message: null, field: null };
return { card };
};
const mapDispatch = { setCard };
export default connect(mapState, mapDispatch)(ExclusiveSelectboxesFormSectionView);
I'm trying to test the non-connected component using react-native-testing-library. The component works in the app, but this test is failing to find "Second field option 2" in the next-to-last assertion in the test.
// non-connected component
import { ExclusiveSelectboxesFormSectionView } from "../../src/components/ExclusiveSelectboxesFormSectionView";
function createWrapper(customProps) {
let mockCard = { message: null, field: null };
const props = {
fields,
setCard: jest.fn().mockImplementation((card: Types.Card) => {
mockCard = card;
}),
card: mockCard,
...customProps
};
wrapper = render(
<Fragment>
<ExclusiveSelectboxesFormSectionView fields={fields} {...props} />
</Fragment>
);
return wrapper;
}
describe("ExclusiveSelectboxesFormSectionView", () => {
let checkboxes;
beforeEach(() => {
wrapper = createWrapper();
checkboxes = wrapper.getAllByType(CheckBox);
expect(checkboxes.length).toBe(3);
});
fit("shows the value of the currently selected field", async () => {
await fireEvent.press(checkboxes[1]); // show options
await fireEvent.press(wrapper.getByText("Second field option 2")); // select option
const component = wrapper.getByType(ExclusiveSelectboxesFormSectionView);
expect(component.props.setCard).toHaveBeenCalled();
// options should be gone
expect(wrapper.queryByText("Second field option 1")).toBeNull();
// selected option should still be on screen
expect(wrapper.getByText("Second field option 2")).toBeDefined();
expect(checkboxes[1].props.checked).toBe(true);
});
});
I've passed in a card prop and a setCard mock function prop, in place of redux providing these.
The mock setCard function is being called, so I think the problem is that the component is not rerendering with its new props (and a newly set card prop). A log statement in the component's render function confirms this (it only prints once when the test is run).
I imagine there's something basic I'm missing about how I'm rendering the component, or wrapping it, or calling it, or something.
Can anyone spot my problem?
It looks like react-native-testing-library's update function does the trick. But it was a bit tough to figure out.
// refactored from createWrapper
function getWrapperProps() {
return {
fields,
setCard: jest.fn().mockImplementation((card: Types.Card) => {
mockCard = card;
}),
card: mockCard
};
}
function createWrapper(customProps) {
wrapper = render(
<Fragment>
<ExclusiveSelectboxesFormSectionView
{...getWrapperProps()}
{...customProps}
/>
</Fragment>
);
return wrapper;
}
function updateWrapper(customProps) {
wrapper.update(
<Fragment>
<ExclusiveSelectboxesFormSectionView
{...getWrapperProps()}
{...customProps}
/>
</Fragment>
);
checkboxes = wrapper.getAllByType(CheckBox);
}
// call updateWrapper() when you need to get the newly rendered props
it("shows the value of the currently selected field", async () => {
await fireEvent.press(checkboxes[1]);
await fireEvent.press(wrapper.getByText("Second field option 2"));
const component = wrapper.getByType(ExclusiveSelectboxesFormSectionView);
expect(component.props.setCard).toHaveBeenCalled();
updateWrapper();
// options should be gone
expect(wrapper.queryByText("Second field option 1")).toBeNull();
// selected option should still be on screen
expect(wrapper.getByText("Second field option 2")).toBeDefined();
expect(checkboxes[1].props.checked).toBe(true);
});
Now it passes.
I'd love to know of other options. I'm not clear why this had been working with an earlier implementation, although the earlier implementation used state in the component, which I'm sure is basically the answer.

Resources