3

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.

MkMan
  • 1,779
  • 1
  • 14
  • 27
  • I'm not understanding what exactly your asking, or what is being presented in that codesandbox. Can you please explain. If you are trying to restrict what type of children a component may have, or what type of props those child components may have, take a gander at [How do I restrict the type of React Children in TypeScript, using the newly added support in TypeScript 2.3?](https://stackoverflow.com/questions/44475309/how-do-i-restrict-the-type-of-react-children-in-typescript-using-the-newly-adde) – Seth Lutske Jul 13 '21 at 22:32
  • I want a component that takes a set of defined props (X) and some additional arbitrary ones (Y). Part of X will be Components, those components should be passed a set of defined props (Z) AND Y from before. – MkMan Jul 13 '21 at 22:37
  • 1
    I believe this would be way better than using a lot of props configuration: https://kentcdodds.com/blog/compound-components-with-react-hooks – Noriller Jul 13 '21 at 23:13
  • Thanks, @Noriller , currently trialing Compound components with Context and Render props and it seems much nicer and more readable. – MkMan Jul 14 '21 at 02:35

5 Answers5

3

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

brietsparks
  • 4,776
  • 8
  • 35
  • 69
1

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.

nullromo
  • 2,165
  • 2
  • 18
  • 39
  • 1
    This is a nicer way of doing it but making `isOpen` optional would require null checks on it which isn't great especially that it's always present. – MkMan Jul 19 '21 at 22:27
1

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)} />
}
dlq
  • 2,743
  • 1
  • 12
  • 12
1

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

-1

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}
    </>
  );
};
gunwin
  • 4,578
  • 5
  • 37
  • 59