'How to properly write types for an array of generic React components and their properties?

I'm trying to properly type the following scenario:

  1. I have a manager/service that contains a collection of items, each item itself consisting of a React component and it's Props.

  2. A method on the manager class takes a Component and the Props as parameters and adds it to the collection.

  3. A third component subscribes to the collection and renders each component with it's props.

  4. Each component that is to be added to the collection have some common properties including some that have generic types.

The code (without types to simply convey the idea) is something like this:

// ModalService.ts
class ModalService {
  modals = [];

  addModal(component, props) {
    this.modals.push({ component, props });
  }
}

// ModalManager.tsx
const ModalManager = () => {
  const modalService = useInjector(ModalService); // our custom DI injector

  return (
    <div className="modal-collection">
      {modalService.modals.forEach(({ Component, props }) => {
        <Component {...props} />
      })}
    </div>
  )
}

// AboutModal.tsx
const AboutModal = (props) => {
  return (
    <div className={`about-modal ${props.show ? 'is-visible' : ''}`}>
      Hi {props.name}, welcome to this bizarre example

      <button onClick={() => props.onHide(true)}>
        Close
      </button>
    </div>
  )
}

// RandomNumber.tsx
const RandomNumberModal = (props) => {
  const randomNum = Math.round(Math.random() * (props.max - props.min)) + props.min;

  return (
    <div className={`random-modal ${props.show ? 'is-visible' : ''}`}>
      Today's random number is: {randomNum}

      <button onClick={() => props.onHide(randomNum)}>
        Close
      </button>
    </div>
  )
}

// elsewhere in the app.tsx?
modalService.addModal(AboutModal, { 
  show: false, 
  onHide: () => void 
})

modalService.addModal(RandomNumberModal, {
  show: true,
  onHide: (num) => saveResult(num),
  min: 500,
  max: 1000
})

The problem I'm stuck with is coming up with properly typed definitions for the ModalService members when TypeScript is running strict mode. Right now, my types look like this:

interface SharedModalProps<TResult> {
  show: boolean;
  onHide?: (result: TResult) => void;
}

interface AboutModalProps extends SharedModalProps<boolean> {
  name: string;
}

interface RandomNumberModalProps extends SharedModalProps<number> {
  min: number;
  max: number;
}

interface ModalItem {
  // cant use ComponentType<SharedModalProps<TResult>> here because the type parameter for
  // TResult can be anything here
  // Ideally, I should be able to do something like ComponentType<T extends SharedModalProps<TResult>> or something similar
  Component: React.ComponentType;
  props: ComponentProps<ModalItem['Component']>;
}

export type ModalsArray = ModalItem[];

export type AddModalFn = <TModal extends React.ComponentType>(
  component: TModal,
  props: React.ComponentProps<TModal>
) => void;

With this configuration, the compiler complains about the modal components being passed to the addModal(component, props) function being incompatible with the defined type.

enter image description here

If I disable strict mode in tsconfig.json, then this error goes away and the correct types are resolved for the props parameter and completions for them work as well. But disabling Strict mode isn't really a solution.

What would be the correct way to type these components and the service so the compiler is able to infer the correct types and the editor is able to offer proper completions etc. as well?

I have this sample code up and running in a Code Sandbox here for reference.



Solution 1:[1]

Try this type. All errors now shown

export default class ModalManagerService {
  @observable
  modals: ModalItem[] = [];

  addModal<TProps, TModal extends React.ComponentType<TProps>>(Component: TModal & React.ComponentType<TProps>, props: TProps) {
    this.modals.push({ Component, props });

    // expect props to have the shared types from SharedModalProps
  }
}

I use this for reference: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react-router/index.d.ts

Solution 2:[2]

If you don't need to keep track of the props, you could just store wrappers of the components you need to render instead of storing the component and props separately.

Service

interface ModalItem {
  Component: React.ComponentType;
}
...
  addModal(Component: React.ComponentType) {
    this.modals.push({ Component });
  }
...

App

...
          service.addModal(() => (
            <AboutModal show name="Sierra-117" onHide={(result: boolean) => console.log('test')} />
          ));
...

ModalManager

      {managerService.modals.map(({ Component }, index) => {
        return <Component />;
      })}

Solution 3:[3]

here is your code with types, it validates that you pass the correct props as well. You can do the same with interfaces if you prefer them. and link to ts playground.

type ModalProps<T> = {
  show: boolean,
  onHide: (val: T) => void
}

class ModalService {
  modals: Array<{Component:React.FunctionComponent<any>, props: ModalProps<any>}> = [];

  addModal<T extends ModalProps<any>>(Component: React.FunctionComponent<T>, props: T) {
    this.modals.push({ Component, props });
  }
}

const modalService = useInjector(ModalService); // our custom DI injector

const ModalManager = () => {
  return (
    <div className="modal-collection">
      {modalService.modals.map(({ Component, props }) => {
        return <Component {...props} />
      })}
    </div>
  )
}

const AboutModal = (props: {name: string} & ModalProps<boolean>) => {
  return (
    <div className={`about-modal ${props.show ? 'is-visible' : ''}`}>
      Hi {props.name}, welcome to this bizarre example

      <button onClick={() => props.onHide(true)}>
        Close
      </button>
    </div>
  )
}

const RandomNumberModal = (props: {max: number, min: number} & ModalProps<number>) => {
  const randomNum = Math.round(Math.random() * (props.max - props.min)) + props.min;

  return (
    <div className={`random-modal ${props.show ? 'is-visible' : ''}`}>
      Today's random number is: {randomNum}

      <button onClick={() => props.onHide(randomNum)}>
        Close
      </button>
    </div>
  )
}

modalService.addModal(AboutModal, { 
  onHide: () => {},
  show: false,
  name: 'asdf',
});

function useInjector(Service: typeof ModalService) {
  return new Service();
}

Solution 4:[4]

  1. you can install @types/react as dependency in codesandbox.

  2. after that, your mentioned error appears. (sandbox)

  3. the errors says the type of AboutModal is FC<AboutModalProps> (a functional component with props of type AboutModalProps), but it's not assignable to type React.ComponentType<{}> (a react component with unspecified prop types).

  4. one way is to accept any prop types in your component:

// CHANGE:
Component: React.ComponentType
// TO:
Component: React.ComponentType<any> // accept component with any props

With adding <any> (as component props), the component accepts the props of type AboutModalProps and the error is gone. (sandbox)

  1. Now, we can replace any with a more specific type like allModalProps:
export interface allModalProps extends SharedModalProps<any> {
  // put all of the possible modal props here.
  name: string;
  min: number;
  max: number;
}

Now let's use the above declaration instead of React.ComponentType<any>:

Component: React.ComponentType<allModalProps> // replaced any with allModalProps

This way, we defined a modal props interface, and we used this interface as component props, instead of any. and we have no type errors.

Here is the sandbox: Sandbox

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Фарид Ахмедов
Solution 2 diedu
Solution 3 Alissa
Solution 4 yaya