Reputation: 2191
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
Reputation: 33041
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
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>[]}
/>
Upvotes: 3
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
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
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