reactjstypescriptmaterial-uinested-listsreact-state-management

How do I Populate Multiple collapsible Material-UI Nested Lists from an Array and Have Separate State


I want my SideMenu to have multiple list items that can open and collapse separately, displaying nested items. I end up getting list items that expand and collapse simultaneously.

Tried this:

const authNavigation = [
    {
      name: 'Organization Management', icon: <OrgIcon/>,
      subMenu: [
        { name: 'Accounts', to: './organization/accounts' },
        { name: 'Organization Details', to: './organization/details' },
      ]
    },
    {
      name: 'Tasks', icon: <TasksIcon/>,
      subMenu: [
        { name: 'Home', to: './tasks' },
        { name: 'New Task', to: './Tasks/create' },
        { name: 'Requests', to: './tasks/requests' },
        { name: 'Feedback', to: './tasks/feedback' }
      ]
    },
    { name: 'Recruiting', to: './recruiting', icon: <RecruitingIcon /> },
    { name: 'Announcements', to: './announcementrs', icon: <AnnouncementsIcon/> },
    checkAccess({ allowedRoles: [ROLES.ADMIN] }) && {
      name: 'Users',
      to: './users',
      icon: UsersIcon,
    },
  ].filter(Boolean) as SideNavigationItem[];

const DrawerInner = () => {
    const [open, setOpen] = React.useState(false);

    const handleClick = () => {
      setOpen(!open)
    };

    return (
       <div>
        <List>
          {authNavigation.map((item, index) => {
            // if there is a submenu for this navigation item, display a nested menu
            if (item.subMenu) {
              return (
                <div key={item.name}>
                  <ListItemButton onClick={handleClick}>
                    <ListItemIcon>
                      {item.icon}
                    </ListItemIcon>
                    <ListItemText primary={item.name} />
                    {open ? <ExpandLess /> : <ExpandMore />}
                  </ListItemButton><Collapse in={open} timeout="auto" unmountOnExit>
                    <List component="div" disablePadding>
                      {item.subMenu.map((item, index) => (
                        <NavLink to={item.to} key={item.name}>
                          <ListItemButton sx={{ pl: 4 }}>
                            <ListItemText primary={item.name} />
                          </ListItemButton>
                        </NavLink>
                      ))}
                    </List>
                  </Collapse>
                </div>
              )
            // list items without a submenu
            } else {
              return (
                <div key={item.name}>
                  <NavLink to={item.to}>
                    <ListItem disablePadding>
                      <ListItemButton>
                        <ListItemIcon>
                          {item.icon}
                        </ListItemIcon>
                        <ListItemText primary={item.name} />
                      </ListItemButton>
                    </ListItem>
                  </NavLink>
                </div>
              )
            }
          }
          )}
        </List>
      </div >
    )
  };

And get this: result image Both drop downs open simultaneously because they share the same open state variable.

How can I manage state between these list items so they open/close separately, given that the list is populated from the authNavigation array? (Typescript please)


Solution

  • You first need to establish an initial state for each of your routes. Since each route needs to track it's own boolean flag, you could use the name property as a key in this new initial state object.

    To create this initial state from your existing array of information, use the .map() method to form a new array of arrays of key-value pairs. Once you have these pairs defined, you can then use Object.fromEntries() method to form a new object of false boolean values for each key name.

    For example, once you have defined authNavigation, below you would write:

    const initialState = Object.fromEntries(authNavigation.map((i) => ([i.name, false])));
    

    The resulting object looks like:

    {
      'Organization Management': false,
      'Tasks': false,
      'Recruiting': false,
      'Announcements': false
    }
    

    Now you feed this initial state into your current state:

    const [open, setOpen] = useState(initialState);

    Your handleClick function will need to be updated as well to know "what" tab needs to be toggled:

    const handleClick = (itemName: string) => {
      setOpen((o) => ({ ...initialState, [itemName]: !o[itemName] }));
    };
    

    What's happening above is the handleClick function now takes an input parameter which is the key of the new initialState object. We first need to bring in the existing state value using setOpen((o) ... where o is the non-stale version of open. So this essentially sets the initial state again, then sets the tab we're interacting with to be the opposite of what it currently is.

    Finally down below where you pass the handler, you need to use:

    <ListItemButton onClick={() => handleClick(item.name)}>

    Here's an example sandbox of your code (stripped down a bit) that demonstrates this functionality.