MkMan
MkMan

Reputation: 2191

React TS Generic component to pass generic props to children

I want to create a React component that is somewhat like an Accordion. It will have children and allow opening/closing each. Each child is also a React component that needs unique props from the parent that other children may not use. I assumed I need to use Generics to facilitate these varying props.

I have this CodeSandbox project with my attempt.

Upvotes: 3

Views: 8529

Answers (5)

I used Currying.

It is easier to infer all generics.

Curry

This function takes two components and thanks to variadic tuple types infers each component. More about inference you can find in my article

Component validation

Curry function also validates each component to make sure each accepts BaseProps.

import React, { FC } from "react";

type BaseProps = {
  isOpen: boolean;
};

const WithTitle: FC<BaseProps & { title: string }> = ({ isOpen, title }) => (
  <p>
    Component 1: isOpen: {isOpen.toString()}. Title: {title}
  </p>
);
const WithCount: FC<BaseProps & { count: number }> = ({ isOpen, count }) => (
  <p>
    Component 2: isOpen: {isOpen.toString()}. Count: {count}
  </p>
);

type GetRequired<T> =
  // make sure we have a deal with array
  T extends Array<infer F>
    ? // make sure that element in the array extends FC
      F extends FC<infer Props>
      ? // if Props extends BaseProps
        Props extends BaseProps
        ? // Omit isOpen property, since it is not needed
          Omit<Props, "isOpen">
        : never
      : never
    : never;

type ExtractProps<F extends FC<any>> = F extends FC<infer Props>
  ? Props
  : never;

type IsValid<Components extends Array<FC<BaseProps>>> = 
    ExtractProps<[...Components][number]> extends BaseProps ? Components : never

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

const Curry =
  <Comps extends FC<any>[], Valid extends IsValid<Comps>>(
    /**
     * If each Component expects BaseProps,
     * sections argument will evaluate to [...Comps] & [...Comps],
     * otherwise to [...Comps] & never === never
     */
    sections: [...Comps] & Valid
  ) =>
  /**
   * GetRequired<[...Comps]> returns a union
   * when we need an intersection of all extra properties from 
   * all passed components
   *
   */
  (props: UnionToIntersection<GetRequired<[...Comps]>>) =>
    (
      <>
        {sections.map((Comp: FC<BaseProps>) => (
          <Comp isOpen={true} {...props} /> // isOpen is required
        ))}
      </>
    );

const Container = Curry([WithCount, WithTitle]);

const result = <Container title={"hello"} count={42} />; // ok

const result_ = <Container title={"hello"} count={"42"} />; // expected error

const Container_ = Curry([WithCount, () => null]); // expected error


As you might have noticed, dealing with FC<Props> is tricky. I have used UnionToIntersection. This is because Props is in contravariant position to FC.

declare var foo: FC<BaseProps & { count: number }>;
declare var baz: FC<BaseProps>;

foo = baz // ok
baz = foo // error

// REMOVE FC wrapper

declare var foo_: BaseProps & { count: number };
declare var baz_: BaseProps;

foo_ = baz_ // error
baz_ = foo_ // ok

Upvotes: 1

brietsparks
brietsparks

Reputation: 5006

To pass components as a prop, use ComponentType instead of FC

type SubSectionToggleProps<T> = {
  subSections: ComponentType<SubSectionBaseProps>[];
} & T;

You might have to type-assert the passed-in components

<SubSectionToggle<{ title: string; count: number }>
  title="Hello"
  count={2}
  subSections={[Component1, Component2] as ComponentType<SubSectionBaseProps>[]}
/>

CodeSandbox

Upvotes: 3

dlq
dlq

Reputation: 3219

You can specify generic prop types, and let TypeScript handle the inference accordingly. TypeScript is pretty gosh darn good at inferring generic type arguments.

type AccordionProps<T> = {
  value: T
  onChange: (newValue: T) => unknown
}

function Accordion<T>(props: AccordionProps<T>) {
  ...
}

function Parent() {
  // newValue correctly typed as (newValue: string) => unknown
  return <Accordion value="asdf" onChange={(newValue) => doSomething(newValue)} />
}

Upvotes: 1

nullromo
nullromo

Reputation: 2637

One way you can render the child isOpen props when they are managed by the container is like this:

import React from 'react';
import "./styles.css";

// the container is your list
interface ContainerProps {
}
interface ContainerState {
  // keep an array of isOpen variables in the state of the container
  open: boolean[];
}
class Container extends React.Component<React.PropsWithChildren<ContainerProps>, ContainerState> {
  public constructor(props: React.PropsWithChildren<ContainerProps>) {
    super(props);
    // initialize the isOpens
    this.state = {
      open: React.Children.toArray(this.props.children).map((_) => {return false;}),
    };
  }

  public readonly render = () => {
    // clone the children and specify their isOpen props here
    return (
     <>
       {React.Children.map(this.props.children, (child, i) => {
         if(React.isValidElement(child)) {
           return(React.cloneElement(child, {isOpen: this.state.open[i]}));
         }
         return child;
       })}
     </>
    );
  };
}

// props for all list items to extend. The isOpen is allowed to be undefined for when the component is not in a list
interface ComponentProps {
  isOpen?: boolean;
}

// component A uses a number
interface ComponentAProps extends ComponentProps {
  count: number;
}
class ComponentA extends React.Component<ComponentAProps> {
  public readonly render = () => {
    return (
      <p>
        {`count: ${this.props.count}, open: ${this.props.isOpen}`}
      </p>
    );
  };
}

// component B uses a string
interface ComponentBProps extends ComponentProps {
  title: string;
}
class ComponentB extends React.Component<ComponentBProps> {
  public readonly render = () => {
    return (
      <p>
        {`title: ${this.props.title}, open: ${this.props.isOpen}`}
      </p>
    );
  };
}

export default function App() {
  return (
    <Container>
      <ComponentA count={2}/>
      <ComponentB title='hello'/>
    </Container>
  );
}

You make the isOpen prop optional. That way the list component can feed in the isOpen to the children, while the specification of the children can ignore the isOpen prop.

Upvotes: 1

gunwin
gunwin

Reputation: 4832

Instead of passing components in as a prop, pass them in as child components:

<SubSectionToggle>
   <Component1 title={"Hello"} isOpen={true} />
   <Component2 count={2} isOpen={true} />
</SubSectionToggle>

You can then render the children like this:

const SubSectionToggle = ({children}): ReactElement => {
  return (
    <>
      {children}
    </>
  );
};

Upvotes: -1

Related Questions