Search code examples
reactjstypescriptreact-forwardref

How to type React's forwardRef with useImperativeHandle in TypeScript?


Here is a simplified version of a Tabs component that exposes a method called activateTab through useImperativeHandle:

type TabsProps<TabName> = {
  tabs: readonly { name: TabName }[];
  children: ReactNode;
};

const TabsComponent = <TabName extends string>(
  props: TabsProps<TabName>,
  ref: Ref<{ activateTab: (tabName: TabName) => void }>
) => {
  const { tabs, children } = props;

  useImperativeHandle(ref, () => ({
    activateTab: (tabName: TabName) => {}
  }));

  return (
    <div>
      <div role="tablist">
        {tabs.map(({ name }) => (
          <button type="button" role="tab" key={name}>
            {name}
          </button>
        ))}
      </div>
      {children}
    </div>
  );
};

const Tabs = forwardRef(TabsComponent);

// Usage:

const tabs = [{ name: "Tab 1" }, { name: "Tab 2" }] as const;

export default function App() {
  return (
    <Tabs tabs={tabs}>
      {...}
    </Tabs>
  );
}

All is good until this point. Here is a working CodeSandbox.

But, now I want to add a Tabs.Panel component so the usage is:

export default function App() {
  return (
    <Tabs tabs={tabs}>
      <Tabs.Panel>Content</Tabs.Panel>
    </Tabs>
  );
}

I tried the following, but TypeScript complains:

type PanelProps = {
  children: ReactNode;
};

const Panel = ({ children }: PanelProps) => {
  return <div role="tabpanel">{children}</div>;
};

Tabs.Panel = Panel;
     ~~~~~
       ^
       Property 'Panel' does not exist on type 'ForwardRefExoticComponent<TabsProps<string> & RefAttributes<{ activateTab: (tabName: string) => void; }>>'

What's the best way to achieve this Tabs.Panel API in TypeScript?

Non-working CodeSandbox


Solution

  • You need to use Object.assign:

    import React, { forwardRef, ReactNode, Ref, useImperativeHandle, FC } from "react";
    
    type PanelProps = {
      children: ReactNode;
    };
    
    const Panel = ({ children }: PanelProps) => {
      return <div role="tabpanel">{children}</div>;
    };
    
    type TabsProps<TabName> = {
      tabs: readonly { name: TabName }[];
      children: ReactNode;
    };
    
    const TabsComponent = <TabName extends string>(
      props: TabsProps<TabName>,
      ref: Ref<{ activateTab: (tabName: TabName) => void }>
    ) => {
      const { tabs, children } = props;
    
      useImperativeHandle(ref, () => ({
        activateTab: (tabName: TabName) => { }
      }));
    
      return (
        <div>
          <div role="tablist">
            {tabs.map(({ name }) => (
              <button type="button" role="tab" key={name}>
                {name}
              </button>
            ))}
          </div>
          {children}
        </div>
      );
    };
    
    const Tabs = Object.assign(forwardRef(TabsComponent), { Panel });
    
    const tabs = [{ name: "Tab 1" }, { name: "Tab 2" }] as const;
    
    export default function App() {
      return <Tabs tabs={tabs}>
        <Tabs.Panel>Content</Tabs.Panel> // ok
      </Tabs>;
    }
    
    

    Playground

    Here you can find more explanation about using static properties on functions