reactjstypescriptnext.jstabsheadless-ui

'Rendered fewer hooks than expected. This may be caused by an accidental early return statement' with HeadlessUI tabs


I am building an admin panel for a shop and using HeadlessUI to build it with <Tab> instead of pages. With this approach tabs work correctly but then I tried to add Roles for administrators, so I created an object with allowedTabs:

export const allowedTabs = {
   admin: {
      shop: [EShopTab.ORDERS, EShopTab.PRODUCTS, EShopTab.STATISTICS],
      system: [ESystemTab.USERS, ESystemTab.ADMINS, ESystemTab.SETTINGS],
   },
   moderator: {
      shop: [EShopTab.ORDERS, EShopTab.PRODUCTS],
      system: [ESystemTab.SETTINGS],
   },
   accountant: {
      shop: [EShopTab.STATISTICS],
      system: [ESystemTab.SETTINGS],
   },
   analyst: {
      shop: [EShopTab.STATISTICS],
      system: [ESystemTab.SETTINGS],
   },
};

after that I'm mapping through them in tabs and tab.panels:

SidebarTabs.tsx:

export const SidebarTabs = () => {
   return (
      <Tab.List className={s.tabs}>
         {Object.keys(tabs).map((key, index) => (
            <SidebarSection sectionKey={key as ESidebarSectionKey} key={index} />
         ))}
      </Tab.List>
   );
};

SidebarSection.tsx:

export const SidebarSection = ({ sectionKey }: TProps) => {
   const { admin } = useSelector((state: RootState) => state.admins);

   const t = useTranslations('Sidebar');

   return (
      <section className={s.sidebarSection}>
         <h1 className={s.title}>{t(sectionKey)}</h1>

         <section className={s.sectionLinks}>
            {allowedTabs &&
               allowedTabs[admin.role] &&
               Object.values(allowedTabs[admin.role][sectionKey]).map((key, index) => (
                  <SidebarTab key={index} label={key} />
               ))}
         </section>
      </section>
   );
};

SidebarTab.tsx:

export const SidebarTab = ({ label }: TProps) => {
   const t = useTranslations('Sidebar.Tabs');

   return (
      <Tab as={Fragment}>
         {({ selected }) => (
            <button
               className={cn(s.tab, {
                  [s.active]: selected,
               })}
            >
               {t(label)}
            </button>
         )}
      </Tab>
   );
};

After I have tabs I am rendering their panels in another component. As I cannot just render 4 tabs and 8 panels (because 1st tab will relate to the wrong panel) I am also mapping through allowedTabs:

Workspace.tsx:

export const Workspace = () => {
   const { admin } = useSelector((state: RootState) => state.admins);

   return (
      <section className={s.workspace}>
         <Tab.Panels>
            {Object.values(allowedTabs[admin.role].shop).map((key, index) => (
               <Tab.Panel key={index}>{panelByAllowedTab[key]}</Tab.Panel>
            ))}
            {Object.values(allowedTabs[admin.role].system).map((key, index) => (
               <Tab.Panel key={index}>{panelByAllowedTab[key]}</Tab.Panel>
            ))}
         </Tab.Panels>
      </section>
   );
};

Rendering panel (Considering @Konrad comment changed all panels to OrdersTab, problem remains): tabs.ts:

export const panelByAllowedTab = {
   [EShopTab.ORDERS]: OrdersTab,
   [EShopTab.PRODUCTS]: OrdersTab,
   [EShopTab.STATISTICS]: OrdersTab,
   [ESystemTab.USERS]: OrdersTab,
   [ESystemTab.ADMINS]: OrdersTab,
   [ESystemTab.SETTINGS]: OrdersTab,
};

Adding OrdersTab:

export const OrdersTab = () => {
   const dispatch = useDispatch();

   const { orders } = useSelector((state: RootState) => state.orders);
   const { products } = useSelector((state: RootState) => state.products);

   const t = useTranslations('Headers.Orders');

   useEffect(() => {
      dispatch(fetchProducts());
      dispatch(fetchOrders());
   }, []);

   return (
      <TabLayout title="Orders">
         <section className={s.content}>
            <div className={s.header}>
               <p className={s.orderNumber}>{t('orderNumber')}</p>
               <p className={s.orderDate}>{t('date')}</p>
               <p className={s.status}>{t('status')}</p>
               <p className={s.orderTotal}>{t('total')}</p>
               <p className={s.orderDelivery}>{t('delivery')}</p>
               <p className={s.orderAddress}>{t('address')}</p>
               <p className={s.orderPayment}>{t('payment')}</p>
            </div>

            <Button
               onClick={() => {
                  dispatch(
                     createOrder({
                        status: EStatus.NEW,
                        totalprice: 0,
                        createdat: new Date().toISOString(),
                        deliverytype: EDelivery.PICKUP,
                        address: 'St',
                        paymenttype: EPayment.CARD,
                        contactphone: '37721732',
                        paymentstatus: EPaymentStatus.NOT_PAID,
                        filters: [
                           {
                              id: 'd8171941-f4f0-40b7-8642-19bc206d89ec',
                              filters: [
                                 {
                                    label: 'Weight',
                                    value: '1',
                                    extraprice: 0,
                                 },
                                 {
                                    label: 'Color',
                                    value: '#381232',
                                    extraprice: 0,
                                 },
                              ],
                           },
                        ],
                        ordernumber: `${orders.length + 1}`,
                        name: 'Name',
                        products: ['d8171941-f4f0-40b7-8642-19bc206d89ec'].map(id => {
                           return products.find(
                              product => product.id === id,
                           ) as TProductDTO;
                        }),
                        id: crypto.randomUUID() as UUID,
                     }),
                  );
               }}
            >
               Add order
            </Button>

            {orders &&
               orders.length > 0 &&
               orders.map(order => <OrdersCard key={order.id} id={order.id} />)}
         </section>
      </TabLayout>
   );
};

and TabLayout:

export const TabLayout = ({ children, title }: TProps) => {
   return (
      <section className={s.tab}>
         <h1 className={s.title}>{title}</h1>

         <Divider />

         {children}
      </section>
   );
};

With this tabs and panels rendering when I try to click on another panel I receive:

Unhandled Runtime Error
Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

Solution

  • What was wrong?

    A few things happened here:

    1. I'm not exactly sure about that but it looks like Tab.Panel can accept a function as child (Tabs can for sure read more)

    2. OrdersTab is treated as a function if not used as <OrdersTab />

    So OrdersTab was probably called as a function a different number of times which caused useDispatch to be called

    Solution

    Either replace [EShopTab.ORDERS]: OrdersTab with [EShopTab.ORDERS]: <OrdersTab />

    Or define Workspace component like this:

    export const Workspace = () => {
       const { admin } = useSelector((state: RootState) => state.admins);
       // any name, but must be capitalized
       const Component = panelByAllowedTab[key]
    
       return (
          <section className={s.workspace}>
             <Tab.Panels>
                {Object.values(allowedTabs[admin.role].shop).map((key, index) => (
                   <Tab.Panel key={index}><Component /></Tab.Panel>
                ))}
                {Object.values(allowedTabs[admin.role].system).map((key, index) => (
                   <Tab.Panel key={index}><Component /></Tab.Panel>
                ))}
             </Tab.Panels>
          </section>
       );
    };