Home > database >  React sub-component expander and shared state
React sub-component expander and shared state

Time:01-13

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>
      <DropdownTitle>Dropdown One</DropdownTitle>
      <DropdownBody>Some content in the body</DropdownBody>
    </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;

CodePudding user response:

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.

CodePudding user response:

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>

CodePudding user response:

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