React sub-component expander and shared state - javascript

I'm wanting to create an expandable section with heading that when clicked toggles the expandable section to show/hide.
I have done this before with regular components etc, but this time I am trying to do this with sub-components and am coming a bit stuck with how to get the state working...
Should I be trying to pass the states into the sub-components directly in the main expander component, or should I be trying to use a context to share the state?
For context, I was reading this article which didn't delve into passing functions (helpful, I know).
App.js
const App = () => (
<div>
<Dropdown>
<Dropdown.Title>Dropdown One</Dropdown.Title>
<Dropdown.Body>Some content in the body</Dropdown.Body>
</Dropdown>
</div>
);
useExpandDropdown.js Custom hook
const useExpandDropdown = (initialState = false) => {
const [isExpanded, setIsExpanded] = useState(initialState);
const toggleExpand = () => setIsExpanded((prev) => !prev);
return [isExpanded, toggleExpand];
};
export default useExpandDropdown;
Expander.js
import useExpandDropdown from "../Hooks/useExpandDropdown";
import DropdownBody from "./DropdownBody";
import DropdownTitle from "./DropdownTitle";
const Dropdown = ({ children }) => {
const [isExpanded, toggleExpand] = useExpandDropdown();
return <div>{children}</div>;
};
Dropdown.Title = DropdownTitle;
Dropdown.Body = DropdownBody;
export default Dropdown;
ExpanderTitle.js
const DropdownTitle = ({ children }) => {
// I want to access the toggleExpand function in here
return <div>{children}</div>;
}
export default DropdownTitle;
ExpanderBody.js
const DropdownBody = ({ isExpanded, children }) => {
// I want to access the isExpanded state here
return <div>{children}</div>;
}
export default DropdownBody;

There are several ways to do it, and the right choice depends on the specifics—how your components are structured, what they look like and how you're using them.
But for most cases, I would outsource this kind of logic to a 3rd-party library so you can spend time maintaining your app instead. One choice is Headless UI and they have a component called Disclosure that you can use here.
import { Disclosure } from "#headlessui/react";
const App = () => (
<div>
<Disclosure>
<Disclosure.Button>
Dropdown One
</Disclosure.Button>
<Disclosure.Panel>
Some content in the body
</Disclosure.Panel>
</Disclosure>
</div>
);
As you can see, it's very simple, and depending on what exactly you're doing you might not need the Dropdown components at all.
Note that Disclosure.Button renders a button by default, which, depending on your environment, might come with some default styling you might not want. You should either style it or render something different than a button, e.g.:
<Disclosure.Button as={div}>
or
<Disclosure.Button as={DropdownTitle}>
Just remember to add a11y, since it's an interactive element.

One way is to use cloneElement to add the props (isExpanded or toggleExpand) to the children.
I'm using children[0] and children[1] to 'split' the title and body, this could be improved in a number of ways, like [ title, body ] = children if you're sure there are only 2 elements.
Example, press the title to toggle the body
const { useState } = React;
const useExpandDropdown = (initialState = false) => {
const [isExpanded, setIsExpanded] = useState(initialState);
return [isExpanded, () => setIsExpanded((prev) => !prev)];
};
const Dropdown = ({ children }) => {
const [isExpanded, toggleExpand] = useExpandDropdown();
return (
<div>
{React.cloneElement(children[0], { toggleExpand })}
{React.cloneElement(children[1], { isExpanded })}
</div>
)
};
const DropdownTitle = ({ children, toggleExpand }) => <div onClick={toggleExpand}>{children}</div>;
const DropdownBody = ({ children, isExpanded }) => <div>{'Body is: '}{isExpanded ? 'Visible' : 'Hidden'}</div>;
const Example = () => {
return (
<Dropdown>
<DropdownTitle>Title</DropdownTitle>
<DropdownBody>Some content in the body</DropdownBody>
</Dropdown>
)
}
ReactDOM.render(<Example />, document.getElementById("react"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>

Here is another solution, using the render props pattern. In this approach, the state is managed by your main component, and passed to child components as props at render. This is a commonly used patterns in many libraries, e.g. Formik.
The advantage is complete flexibility—your API is open for extension in the future, as you can define the structure of your components without any restrictions. A disadvantage is that it's a little verbose and can result in prop drilling if you child components have several levels of nesting.
const { useState } = React;
const MyDisclosureTitle = ({
children,
onClick,
}) => {
const style = { all: "unset" };
return (
<button onClick={onClick} style={style} type="button">
{children}
</button>
);
};
const MyDisclosureBody = ({ children }) => {
return <div>{children}</div>;
};
const MyDisclosure = ({ children }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const disclosureBag = {
isExpanded,
toggleExpanded,
};
return children(disclosureBag);
};
MyDisclosure.Title = MyDisclosureTitle;
MyDisclosure.Body = MyDisclosureBody;
const Example = () => {
return (
<MyDisclosure>
{({ isExpanded, toggleExpanded }) => (
<div>
<MyDisclosure.Title onClick={toggleExpanded}>
Dropdown One
</MyDisclosure.Title>
{isExpanded && (
<MyDisclosure.Body>Some content in the body</MyDisclosure.Body>
)}
</div>
)}
</MyDisclosure>
);
};
ReactDOM.render(<Example />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
Here is a typescript example: https://codesandbox.io/s/react-disclosure-example-bqrtsk

Related

Change child's state in react

function App(){
const [selectedLang, setSelectedLang] = useState(0);
const [langList, setLangList] = useState([]);
useEffect(()=>{
const list = [];
/* i18n.options.supportedLngs is ['en', 'ko'] */
i18n.options.supportedLngs.map((item, i)=>{
list.push(item);
/* Set selectedLang to i18n's default language.(Default is 'ko')*/
if(item === i18n.language){
setSelectedLang(i);
}
})
setLangList(list);
}, [])
useEffect(()=>{
console.debug("langList :", langList, ",", selectedLang); // <- It print correctly ['en', 'ko'], 1
}, [langList, selectedLang])
return(
<Child defaultIndex={selectedLang} childList={langList}/>
)
}
function Child(props){
const {defaultIndex, childList} = props;
const [selected, setSelected] = useState(0);
useState(()=>{
setSelected(defaultIndex);
},[])
return(
<div>
{childList[selected]} //<- But it print 'en'.
</div>
)
}
The code above is a simplified version of my code.
Currently i18n defaults to 'ko'. I want to display 'ko' in Child by setting selectedLang to 'ko's index in App, and giving the index of 'ko' and the entire language array as props to Child.
However, Child's selected and defaultIndex doesn't seem to change from a state initialized with useState(0).
Can someone help me?
setSelected need to be called after changing defaultIndex in Child component.
And you didn't use proper hook.
useEffect(()=>{
setSelected(defaultIndex);
},[defaultIndex])
You can use react context to achieve this:
First make a Lang provider in app.js
const LangContext = React.createContext('ko');
function App() {
const [lang, setLang] = useState("ko");
return (
<LangContext.Provider value={[lang, setLang]}>
<Toolbar />
</LangContext.Provider>
);
}
Then in any child component you can use :
function Button() {
const [lang, setLang]= useContext(LangContext);
return (
<button onClick={()=>{setLang('en')}}>
{lang}
</button>
);
}
Any change in context will propagate in all components using useContext.
Read more here : https://reactjs.org/docs/hooks-reference.html#usecontext

Props and onChildClick not working together

I have a parent component "Item" and a child component "Order".
Item has a button that toggles whether "Order" is displayed. If book is displayed, it passes the fetched details as props to the Order component, as well as the function for toggling if its open or closed.
Before adding the props to "Order", the toggle worked perfectly. After adding the props, the prop-handling works as it should, but now the function doesn't work. What am i doing wrong?
const Item = () => {
const [item, setItem] = useState('');
const [order, setOrder] = useState('');
//Api call to get item
const orderOpenClose = () => {
setOrder(!order);
};
return (
<>
<div onClick={orderOpenClose}>
<Button text="Order"></Button>
</div>
{order ? <Order acc={item} onChildClick={orderOpenClose}/> : ""}
</>
)
}
const Order = (props, { onChildClick }) => {
const { item } = props;
return (
<>
<div onClick={onChildClick}>
x
</div>
<p>{item.title}</p>
)
}```
This (props, { onChildClick }) is just not correct syntaxis, you can either destruct props or pass them as one object, but not both, so you can do either
const Book = ({acc, onChildClick })
or
const Book = (props) => {
const { acc,onChildClick } = props;

Pass functional component from child to parent in React

Is it possible to pass a functional component from a child component to a parent component? I'm trying to do a dynamic modal that is displayed inside the parent but that the children can populate through a function from a provider, for example:
setModal(() => (
<div>content</div>)
)
And the parent receives this component:
const [modal, setModal] = useState(false)
const [modalContent, setModalContent] = useState<FunctionComponent>()
...
<Provider value={{
setModal: (content: FunctionComponent) => {
setModalContent(content); // This updates the state to hold a function component and to re-render
setModal(true); // This updates a state flag to show the overlay in which the modal is rendered
},
}}>
...
</Provider>
The content of the modal should be dynamic. I was trying to use the state of the component to hold the functional component but I don't think if that's possible or if it's a good practice.
If I understand your question correctly, you're still looking to pass a function from the parent to each child but each child should be able to change the state of a modal component that the parent also has ownership over.
For the above scenario this is something you can do:
const Provider = ({ children, updateModal }) => {
// With this, every child has the ability to call updateModal
return React.Children(children).map(child => cloneElement(child, { updateModal }));
};
const ModalComponent = ({ open, children }) => {
if (!open) return null;
return (
<dialog>
{children}
</dialog>
);
};
const ParentComponent = () => {
const [modal, setModal] = useState(false);
const [modalContent, setModalContent] = useState(null);
const updateModal = (content) => {
setModalContent(content);
setModal(true);
};
return (
<>
<Provider updateModal={updateModal}>
{...insert children here}
</Provider>
<ModalComponent open={modal}>
{modalContent}
</ModalComponent>
</>
);
};

React conditionally render component using an outside function

I'm working on a modal function in an application. Since the app has different modals, I have a function which handles the open & close state of various windows:
OpenItem.jsx
const OpenItem = ({ toggle, content }) => {
const [isShown, setIsShown] = useState(false);
const hide = () => setIsShown(false);
const show = () => setIsShown(true);
return (
<>
{toggle(show)}
{isShown && content(hide)}
</>
);
};
export default OpenItem;
Header.jsx
Now in my main component, I want to to use this function with another component:
const Header = () => {
return (
<div>
<OpenItem
toggle={(show) => <Button onClick={show}>icon</Button>}
content={(hide) => (
// Component to hide:
<ComponentToShowOrHide onClick={hide} />
)}
/>
</div>
);
};
export default Header;
This works fine, except that instead of having the {hide} function as a part of the imported component, I want to toggle the view in <Button onClick={show}>icon</Button>
My idea is to conditionally render the show or hide in the button instead of rendering it in the component, but I'm not quite sure how to do that since I haven't used an outside function to control a function in a component.
Simply write a function that toggles the state rather than sets it to a value.
const OpenItem = ({ toggle, content }) => {
const [isShown, setIsShown] = useState(false);
return (
<>
{toggle(() => setIsShown(prevState => !prevState))}
</>
);
};
export default OpenItem;

Using React Context as part of useEffect dependency array for simple toast notification system

I'm building a simple toast notification system using React Context. Here is a link to a simplified but fully working example which shows the problem https://codesandbox.io/s/currying-dust-kw00n.
My page component is wrapped in a HOC to give me the ability to add, remove and removeAll toasts programatically inside of this page. The demo has a button to add a toast notification and a button to change the activeStep (imagine this is a multi-step form). When the activeStep is changed I want all toasts to be removed.
Initially I did this using the following...
useEffect(() => {
toastManager.removeAll();
}, [activeStep]);
...this worked as I expected, but there is a react-hooks/exhaustive-deps ESLint warning because toastManager is not in the dependency array. Adding toastManager to the array resulted in the toasts being removed as soon as they were added.
I thought I could have fixed that using useCallback...
const stableToastManager = useCallback(toastManager, []);
useEffect(() => {
stableToastManager.removeAll();
}, [activeStep, stableToastManager]);
...however, not only does this not work but I would rather fix the issue at the source so I don't need to do this every time I want this kind of functionality, as it is likely to be used in many places.
This is where I am stuck. I'm unsure as to how to change my Context so that I don't need add additional logic in the components that are being wrapped by the HOC.
export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([]);
const add = (content, options) => {
// We use the content as the id as it prevents the same toast
// being added multiple times
const toast = { content, id: content, ...options };
setToasts([...toasts, toast]);
};
const remove = id => {
const newToasts = toasts.filter(t => t.id !== id);
setToasts(newToasts);
};
const removeAll = () => {
if (toasts.length > 0) {
setToasts([]);
}
};
return (
<ToastContext.Provider value={{ add, remove, removeAll }}>
{children}
<div
style={{
position: `fixed`,
top: `10px`,
right: `10px`,
display: `flex`,
flexDirection: `column`
}}
>
{toasts.map(({ content, id, ...rest }) => {
return (
<button onClick={() => remove(id)} {...rest}>
{content}
</button>
);
})}
</div>
</ToastContext.Provider>
);
};
export const withToastManager = Component => props => {
return (
<ToastContext.Consumer>
{context => {
return <Component toastManager={context} {...props} />;
}}
</ToastContext.Consumer>
);
};
If you want to "Fix it from the core", you need to fix ToastProvider:
const add = useCallback((content, options) => {
const toast = { content, id: content, ...options };
setToasts(pToasts => [...pToasts, toast]);
}, []);
const remove = useCallback(id => {
setToasts(p => p.filter(t => t.id !== id));
}, []);
const removeAll = useCallback(() => {
setToasts(p => (p.length > 0 ? [] : p));
}, []);
const store = useMemo(() => ({ add, remove, removeAll }), [
add,
remove,
removeAll
]);
Then, the useEffect will work as expected, as the problem was that you re-initialized the ToastProvider functionality on every render when it needs to be a singleton.
useEffect(() => {
toastManager.removeAll();
}, [activeStep, toastManager]);
Moreover, I would recommend to add a custom hook feature as the default use case, and providing wrapper only for class components.
In other words, do not use wrapper (withToastManager) on functional components, use it for classes, as it is considered an anti-pattern, you got useContext for it, so your library should expose it.
// # toastContext.js
export const useToast = () => {
const context = useContext(ToastContext);
return context;
};
// # page.js
import React, { useState, useEffect } from 'react';
import { useToast } from './toastContext';
const Page = () => {
const [activeStep, setActiveStep] = useState(1);
const { removeAll, add } = useToast();
useEffect(() => {
removeAll();
}, [activeStep, removeAll]);
return (
<div>
<h1>Page {activeStep}</h1>
<button
onClick={() => {
add(`Toast at ${Date.now()}!`);
}}
>
Add Toast
</button>
<button
onClick={() => {
setActiveStep(activeStep + 1);
}}
>
Change Step
</button>
</div>
);
};
export default Page;

Categories