When designing a generic component, I adopted the principle of SOC where the generic component will not know the implementation details of the user and allow the user to listen for callbacks to handle their own state.
For example:
SortButton.tsx
interface Props {
initialValue?: boolean;
onToggle?: (value: boolean) => void;
}
const Toggle: React.FC<Props> = ({
initialValue = false,
onToggle,
}) => {
const [isActive, setIsActive] = React.useState(initialValue);
const handleToggle = React.useCallback(() => {
let updatedValue = isActive;
// do some computation if needed
if(onToggle) {
onSort(updatedValue);
}
setIsActive(updatedValue);
}, [isActive, onToggle]);
return (
<div onClick={handleToggle}>Sort</div>
);
}
Parent.tsx
const Parent: React.FC<Props> = ({
}) => {
const [parentObject, setParentObject] = React.useState({});
const handleToggle = React.useCallback((value: boolean) => {
let updatedValue = value;
// do some computation to updatedValue if needed
setParentObject((parent) => { ...parent, calculated: updatedValue });
}, []);
return (
<SortButton onToggle={handleToggle} />
);
}
The above implementation allows the generic component (ie. Toggle) to handle their own state and allows parents to implement their own implementation using callbacks. However, I have been getting the following error:
Warning: Cannot update a component (`Parent`) while rendering a different component (`Toggle`). To locate the bad setState() call inside `Toggle`
My understanding from the error is that, within the callback onClick, it triggers state mutation of the parent (in this case, another component) which should not be allowed. The solution I figured was to move the callback within an useEffect like this:
SortButton.tsx
interface Props {
initialValue?: boolean;
onToggle?: (value: boolean) => void;
}
const Toggle: React.FC<Props> = ({
initialValue = false,
onToggle,
}) => {
const [isActive, setIsActive] = React.useState(initialValue);
React.useEffect(() => {
let updatedValue = isActive;
// do some computation if needed
if(onToggle) {
onSort(updatedValue);
}
}, [isActive, onToggle]);
const handleToggle = React.useCallback(() => {
setIsActive((value) => !value);
}, []);
return (
<div onClick={handleToggle}>Sort</div>
);
}
The new implementation works but I have a few questions which I would hope to get some guidance on.
The example works and is easy to refactor because it is a simple state of isActive. What if the value we need is more complicated (for example, mouse position, etc) and does not have a state to store the value and is only available from onMouseMove? Do we create a state to store the `mouse position and follow the pattern?
Is the existing implementation an anti-pattern to any of the React concepts in the first place?
Is there any other possible implementation to solve the issue?
This is a somewhat biased opinion, but I'm a big proponent of Lifting State Up and "Dumb" Components/Controlled Components.
I would design it so that the SortButton does not have any internal state. It would get all of the information that it needs from props. The Parent would be responsible for passing down the correct value of isActive/value, which it will update when the child SortButton calls its onToggle prop.
We can include the event in the onToggle callback just in case the parent wants to use it.
SortButton.tsx
import * as React from "react";
interface Props {
isActive: boolean;
onToggle: (value: boolean, e: React.MouseEvent<HTMLDivElement>) => void;
}
const Toggle: React.FC<Props> = ({ isActive, onToggle }) => {
return (
<div
className={isActive ? "sort-active" : "sort-inactive"}
onClick={(e) => onToggle(!isActive, e)}
>
{isActive ? "Unsort" : "Sort"}
</div>
);
};
export default Toggle;
Parent.tsx
import * as React from "react";
import SortButton from "./SortButton";
interface Props {
list: number[];
}
const Parent: React.FC<Props> = ({ list }) => {
// parent stores the state of the sort
const [isSorted, setIsSorted] = React.useState(false);
// derived data is better as a memo than as state.
const sortedList = React.useMemo(
// either sort the list or don't.
() => (isSorted ? [...list].sort() : list),
// depends on the list prop and the isSorted state.
[list, isSorted]
);
return (
<div>
<SortButton
isActive={isSorted}
// you could use a more complicated callback, but it's not needed here.
onToggle={setIsSorted}
/>
<ul>
{sortedList.map((n) => (
<li>{n.toFixed(3)}</li>
))}
</ul>
</div>
);
};
export default Parent;
Code Sandbox Demo
Related
EDIT: below the line is the initial problem where I asked if my whole architecture was fine. I later edited the topic (and title) to go straight to what was my issue in order to help the community. Therefore, you might find what you want by jumping directly to the answer while skipping the main post
I am new to react and I am encountering issues back to back. I suspect that something is wrong in my pattern and would really love to have it criticized by someone a bit stronger.
Component A:
It renders (among others) a Component B
Component A has a useState hook on an array of C_obj it renders, called C_obj_arr
const [C_obj_arr, ASetter] = useState<C_obj[]>()
It provides this C_obj_arr and ASetter to Component B as properties to allow the data to go back up.
Component B
It renders each C_obj of the C_obj_arr in a list of Component C.
It has a useReducer hook that controls the states of C_obj_arr
const [C_obj_array, dispatch] = useReducer(reducer, props.C_obj_array);
It has a useEffect hook such that if C_obj_arr changes, data goes back up to Compoennt A
useEffect(() => {
ASetter(C_obj_array);
}, [C_obj_array, ASetter]);
Question: Is it fine so far to use the prop like this to init the reducer?
it also uses a useCallback hook to wrap a function that will allow getting the data back from Component C
const fn_callback = useCallback(
(c: C_obj) =>
dispatch({
kind: Kind.AN_ACTION,
payload: { wells_plan: c },
}),
[]
);
Component C
It has another useReducer that controls the states of C_obj
const [C_obj, dispatch] = useReducer(reducer, props.C_obj);
To send the information back up to Component B, it uses the function fn_callback, created in B thanks to a useEffect hook with dep on C_obj
useEffect(() => {
props.fn_callback(C_obj);
}, [C_obj, props.fn_callback]);
I hope it is not a total brain schmuck to read, I am very new to all of that so I understand I can be doing something totally broken by design.
Many thanks for help
EDIT: as requested, here is a block of code to synthetize
const A = (): JSX.Element => {
const [C_obj_arr, ASetter] = useState<C_obj[]>();
return (
<>
<B>C_obj_arr=C_obj_arr ASetter=ASetter</B>
</>
);
};
const B = (C_obj_arr_init: C_obj[], ASetter: () => void): JSX.Element => {
const [C_obj_array, dispatch] = useReducer(reducer, C_obj_arr_init);
useEffect(() => {
ASetter(C_obj_array);
}, [C_obj_array, ASetter]);
const fn_callback = useCallback(
(c_obj: C_obj) =>
dispatch({
kind: Kind.UPDATE_OBJ,
payload: { wells_plan: c_obj },
}),
[]
);
return C_obj_array.map(C_obj => (
<C C_obj={C_obj} fn_callback={fn_callback}></C>
));
};
const C = (C_obj_init, fn_callback): JSX.Element => {
const [C_obj, dispatch] = useReducer(reducer, C_obj_init);
useEffect(() => {
fn_callback(C_obj);
}, [C_obj, fn_callback]);
return <div>{C.toString()}</div>;
};
I assume, that you mean
import { useState, useEffect, useReducer, useCallback } from "react"
type SomeObj = {
name: string
key: string
}
const A = (): JSX.Element => {
const [items, setitems] = useState<SomeObj[]>([
{
key: "a",
name: "hello"
}
])
return (
<>
<B items={items} setitems={setitems} />
</>
)
}
const B = ({ items, setitems }: { items: SomeObj[]; setitems: (x: SomeObj[]) => void }): JSX.Element => {
const [items2, dispatch] = useReducer(
(
x: SomeObj[],
a: {
kind: string
payload: {}
}
) => {
return x
},
items
)
useEffect(() => {
setitems(items2)
}, [items2, setitems])
const fn_callback = useCallback(
(item: SomeObj) =>
dispatch({
kind: "update",
payload: { wells_plan: item },
}),
[]
)
return (
<div>
{items2.map((item) => (
<C itemInit={item} fn_callback={fn_callback} key={item.key}></C>
))}
</div>
)
}
const C = ({ itemInit, fn_callback }: { itemInit: SomeObj; fn_callback: (c_obj: SomeObj) => void }): JSX.Element => {
const [item, dispatch] = useReducer((x: SomeObj) => x, itemInit)
useEffect(() => {
fn_callback(item)
}, [item, fn_callback])
return <div>{item.name}</div>
}
function App() {
return (
<main>
<A></A>
</main>
)
}
export default App
And, I think, it's basically the reason for using global/atom state managers to react like: redux, recoil, jotai, etc.
In your scenario, you have a couple of prop passing layers through components, and only if I understand correctly, you try to take up changes from deeply nested component.
Using global state, you can have only one array and source of truth, and only one handler in each child component. That probably will remove all unnecessary hooks
The missing bolt for this thing to work was to handle the rerendering of component C. The problem was that useReducer initial state is applied only once, then not when the component is rendered. Therefore, there is a need to force the update of the obj_c when re-rendering it. To do that, I added an extra dispatch action in the Component C body.
useEffect(() => {
dispatch({
kind: Kind.UPDATE,
payload: {
obj_C: C_obj_init,
},
});
}, [C_obj_init]);
which associated case in the reducer is
switch (action.kind) {
case Kind.UPDATE:
return action.payload.obj_C;
An important thing point here is to not return a recreated obj_C, this wouldlead to an infinite loop to this other hook seen earlier:
useEffect(() => {
fn_callback(C_obj);
}, [C_obj, fn_callback]);
Here I'm trying to reset selected radio buttons on this list,
however it doesn't work because
I previously change input check from {checked} to {user.checked}. Refer from UserListElement.tsx below
Therefore, I tried the following two methods.
in useEffect(), set user.userId = false
useEffect(() => {
user.checked = false;
}, [isReset, user]);
→ no change.
setChecked to true when addedUserIds includes user.userId
if (addedUserIds.includes(`${user.userId}`)) {
setChecked(true);
}
→ Unhandled Runtime Error
Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
Any suggestion on how to make this this work?
UserListElement.tsx
export const UserListElement = ({
user,
handleOnMemberClicked,
isReset,
}: {
user: UserEntity;
handleOnMemberClicked: (checked: boolean, userId: string | null) => void;
isReset: boolean;
}) => {
const [checked, setChecked] = useState(user.checked);
const addedUserIds = addedUserList.map((item) => item.userId) || [];
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
const checkedState = e.target.checked;
setChecked(checkedState); //not called
user.checked = checkedState;
handleOnMemberClicked(checkedState, user.userId);
};
useEffect(() => {
setChecked(false);
}, [isReset, user]);
if (addedUserIds.includes(`${user.userId}`)) {
user.checked = true;
// setChecked(true) cause runtime error (infinite loop)
}
return (
<li>
<label className={style.checkboxLabel}>
<input
type="checkbox"
className={style.checkboxCircle}
checked={user.checked}
// checked={checked}
onChange={(e) => handleOnChange(e)}
/>
<span>{user.name}</span>
</label>
</li>
);
};
UserList.tsx
export const UserList = (props: {
showsUserList: boolean;handleClose: () => void;corporationId: string;currentUserId: string;samePerson: boolean;twj: string;
}) => {
const [isReset, setReset] = useState(false);
.......
const resetAll = () => {
setReset(!isReset);
setCount((addedUserList.length = 0));
setAddedUserList([]);
setUserName('');
};
......
return ( <
> < div > xxxxx <
ul className = {
`option-module-list no-list option-module-list-member ${style.personListMember}`
} > {searchedUserList.map((user, i) => (
<UserListElement user = { user }
handleOnMemberClicked = { handleOnMemberClicked }
isReset = { isReset }
key = {i} />
)) }
</ul>
/div>
<a className="is-secondary reservation-popup-filter-reset" onClick={resetAll}>
.....
}
UseAddUserList.tsx
export class UserDetail {
constructor(public userId: string | null, public name: string | null) {}
}
export let addedUserList: UserDetail[] = [];
export let setAddedUserList: Dispatch<SetStateAction<UserDetail[]>>;
export const useAddUserList = (idList: UserDetail[]) => {
[addedUserList, setAddedUserList] = useState(idList);
};
Further Clarification:
Default view
Searched option (showed filtered list)
I use user.checked because when using only checked, the checked state does not carry on from filtered list view to the full view (ex. when I erase searched word or close the popup).
The real answer to this question is that the state should NOT be held within your component. The state of checkboxes should be held in UsersList and be passed in as a prop.
export const UserListElement = ({
user,
handleOnMemberClicked,
isChecked
}: {
user: UserEntity;
handleOnMemberClicked: (checked: boolean, userId: string | null) => void;
isChecked: boolean;
}) => {
// no complicated logic in here, just render the checkbox according to the `isChecked` prop, and call the handler when clicked
}
in users list
return searchedUserList.map(user => (
<UserListElement
user={user}
key={user.id}
isChecked={addedUserIds.includes(user.id)} <-- THIS LINE
handleOnMemberClicked={handleOnMemberClicked}
/>
)
You can see that you almost had this figured out because you were doing this in the child:
if (addedUserIds.includes(`${user.userId}`)) {
user.checked = true;
// setChecked(true) cause runtime error (infinite loop)
}
Which indicates to you that the checkdd value is entirely dependent on the state held in the parent, which means there is actually no state to be had in the child.
Also, in React, NEVER mutate things (props or state) like - user.checked = true - that's a surefire way to leave you with a bug that will cost you a lot of time.
Hopefully this sheds some light
In your UserListElement.tsx you are setting state in render, which triggers renders the component again, and again set the state which again triggers re-render and the loop continues. Try to put your condition in the useEffect call, also you mutate props, so don't set user.checked = true. Instead call setter from the parent component, where it is defined.
useEffect(() => {
setChecked(false);
if (addedUserIds.includes(user.userId)) {
setChecked(true);
}
}, [user]);
I'm using a debounce library (tried different ones but currently the one from lodash) in a react component in order to avoid executing code too often while scrolling in the browser.
The problem is that I have multiple instances of the react component and it seems that the debounce function is accidentally shared between those instances. Consequently the function code with '... some code here' is only executed in one instance and not in all instances of the react component. The debounce functionality works great if I have only one instance of my component rendered.
useEffect(() => {
document.querySelector(props.scrollSelector!)?.addEventListener('scroll', e => {
setViewport(props, state, e.target as HTMLDivElement, ref)
}, true)
}, [state.obj])
const setViewport = debounce((p: Props, s: State, rowHeaderObj: any, scrollContainer: HTMLDivElement, ref: any) => {
// ... some code here
}, 20)
Is there some way to change the code so the debounce function works for each instance separately? Please consider that the react component instances have unique keys assigned so that should not be the issue.
One approach could be to create a new debounced function each time you register the event listener instead of reusing the same function, in which case the event handler would be debounced independently within each instance of your component.
const _setViewport = () => (
p: Props,
s: State,
rowHeaderObj: any,
scrollContainer: HTMLDivElement,
ref: any
) => {
// ... some code here
}
const MyComponent: React.FC<Props> = (props) => {
const [state, setState] = useState<State>()
const ref = useRef<any>()
useEffect(() => {
const srollableElement = document.querySelector(props.scrollSelector!)
if (!srollableElement) {
return
}
const setViewport = debounce(_setViewport, 20)
const scrollHandler = (e: Event) =>
setViewport(props, state, e.target as HTMLDivElement, ref)
srollableElement.addEventListener('scroll', scrollHandler, true)
return () => {
srollableElement.removeEventListener('scroll', scrollHandler, true)
}
}, [state, props, ref])
return <></>
}
As a side note, be careful with this usage of useEffect, as (I think) the props parameter that's passed to your component will change each time the parent component re-renders, causing useEffect to potentially re-run very often. One fix for this is making sure the dependencies array passed to useEffect only contains primitive or stable values. Feel free to read this section of the React docs for a discussion of this topic. Taking this into consideration, you might want to re-write the above example as follows (depending on the shape of the Props type):
interface Props {
scrollSelector?: string
b: string
c: number
}
const _setViewport = () => (
p: Props,
s: State,
rowHeaderObj: any,
scrollContainer: HTMLDivElement,
ref: any
) => {
// ... some code here
}
const MyComponent: React.FC<Props> = ({ scrollSelector, b, c }) => {
const [state, setState] = useState<State>()
const ref = useRef<any>()
useEffect(() => {
if (!scrollSelector) {
return
}
const srollableElement = document.querySelector(scrollSelector)
if (!srollableElement) {
return
}
const setViewport = debounce(_setViewport, 20)
const scrollHandler = (e: Event) =>
setViewport(
{ scrollSelector, b, c },
state,
e.target as HTMLDivElement,
ref
)
srollableElement.addEventListener('scroll', scrollHandler, true)
return () => {
srollableElement.removeEventListener('scroll', scrollHandler, true)
}
}, [state, scrollSelector, b, c, ref])
return <></>
}
I have an array that via useState hook, I try to add elements to the end of this array via a function that I make available through a context. However the array never gets beyond length 2, with elements [0, n] where n is the last element that I pushed after the first.
I have simplified this a bit, and haven't tested this simple of code, it isn't much more complicated though.
MyContext.tsx
interface IElement = {
title: string;
component: FunctionComponent<any>;
props: any;
}
interface IMyContext = {
history: IElement[];
add: <T>(title: string, component: FunctionComponent<T>, props: T) => void
}
export const MyContext = React.createContext<IMyContext>({} as IMyContext)
export const MyContextProvider = (props) => {
const [elements, setElements] = useState<IElement[]>([]);
const add = <T extends any>(title: string, component: FunctionComponent<T>, props: T) => {
setElements(elements.concat({title, component, props}));
}
return (
<MyContext.Provider values={{elements, add}}>
{props.children}
</MyContext.Provider>
);
}
In other elements I use this context to add elements and show the current list of elements, but I only ever get 2, no matter how many I add.
I add via onClick from various elements and I display via a sidebar that uses the components added.
SomeElement.tsx
const SomeElement = () => {
const { add } = useContext(MyContext);
return (
<Button onClick=(() => {add('test', MyDisplay, {id: 42})})>Add</Button>
);
};
DisplayAll.tsx
const DisplayAll = () => {
const { elements } = useContext(MyContext);
return (
<>
{elements.map((element) => React.createElement(element.component, element.props))}
</>
);
};
It sounds like your issue was in calling add multiple times in one render. You can avoid adding a ref as in your currently accepted answer by using the callback version of setState:
const add = <T extends any>(title: string, component: FunctionComponent<T>, props: T) => {
setElements(elements => elements.concat({title, component, props}));
};
With the callback version, you'll always be sure to have a reference to the current state instead of a value in your closure that may be stale.
The below may help if you're calling add multiple times within the same render:
interface IElement = {
title: string;
component: FunctionComponent<any>;
props: any;
}
interface IMyContext = {
history: IElement[];
add: <T>(title: string, component: FunctionComponent<T>, props: T) => void
}
export const MyContext = React.createContext<IMyContext>({} as IMyContext)
export const MyContextProvider = (props) => {
const [elements, setElements] = useState<IElement[]>([]);
const currentElements = useRef(elements);
const add = <T extends any>(title: string, component: FunctionComponent<T>, props: T) => {
currentElements.current = [...currentElements.current, {title, component, props}];
setElements(currentElements.current);
}
return (
<MyContext.Provider values={{history, add}}>
{props.children}
</MyContext.Provider>
);
}
I am getting some unexpected results.
Looking at that
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
fooList: PropTypes.array
};
const defaultProps = {
fooList: [
{ active: false },
{ active: false }
];
};
const FooBar = ({
fooList
}) => {
const [state, setState] = React.useState(fooList);
const onClick = (entry, index) => {
entry.active = !entry.active;
state[index] = entry;
console.log('#1', state) // <- That loggs the new State properly!
setState(state);
}
console.log('#2', state) // <- That does not log at after clicking on the text, only after the initial render
return state.map((entry, index) => {
return <p
onClick={() => onClick(entry, index)}
key={index}>
{`Entry is: ${entry.active ? 'active' : 'not active'}`}
</p>
})
}
FooBar.defaultProps = defaultProps;
FooBar.propTypes = propTypes;
export default FooBar;
I expect on every click the text in the <p /> Tag to change from Entry is: not active to Entry is: active.
Now, I am not sure if I can simply alter the state like this
state[index] = entry;
Using a class extending React.Component, this wouldn't work. But maybe with React Hooks? And then, I am not sure if I can use hooks in a map().
When you use state[index] = entry;, you are mutating the state but the state reference does not change, and so React will not be able to tell if the state changed, and will not re-render.
You can copy the state before mutating it:
const onClick = (entry, index) => {
entry.active = !entry.active;
const newState = [...state];
newState[index] = entry;
console.log(newState) // <- That loggs the new State properly!
setState(newState);
}
I would also consider maybe changing up your design a little https://stackblitz.com/edit/react-6enuud
rather than handling each individual click out side, if it is just for display purposes, then it can be easier to encapsulate in a new component:
const FooBarDisplay = (entry) => {
const [active, setActive] = useState(entry.active);
const onClick = () => {
setActive(!active);
}
return (<p onClick={() => onClick()}>
{`Entry is: ${active ? 'active' : 'not active'}`}
</p>
)
}
Here you can make handling state easier, and avoid mutating arrays.
Simpler parent:
const FooBar = ({
fooList = [
{ active: false },
{ active: false }
]
}) => fooList.map((entry, i) => <FooBarDisplay key={i} entry={entry} />);
I've just moved default props to actual default argument values.