DEV Community

Cover image for Understanding React Children types
Daniel Bolívar
Daniel Bolívar

Posted on • Originally published at dabolivar.com

Understanding React Children types

So, here's the deal. I'm not a big fan of React's children property. Don't get me wrong, I know why it's good. I know why it's useful and I also very much know that I don't like to use it much when I'm writing React components. I've seen it used mostly to create stateless wrappers that only add an extra, non-semantic div and a CSS class, resulting in the ever wonderful:

<Wrapper>
  <HeaderWrapper>
    <p>Something</p>
  </HeaderWrapper>
</Wrapper>
Enter fullscreen mode Exit fullscreen mode

That renders into:

<div class="container">
  <div class="header-container">
    <p>Something</p>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This is a simplified example... what I'm trying to say is that my experience with components that make explicit use of children is bad. And I'm biased.

But when Felipe showed me his idea for a component that used children, not just for adding a wrapper, but for making decisions on which child to render based on parent props, I realized I should probably put my bias aside. And this is when we asked ourselves the question to end all questions:

How can I constraint the parent component's children type, to be of a single component type?

And thus, we set out on a mighty journey towards type enlightenment.

Setting out

We started where every journey starts. Five steps further than we should have, by trying to immediately run something on a .tsx file that looked like this:

interface ChildComponentProps {
  a: number;
  b: string;
}

interface ParentComponentProps {
  children: React.ReactElement<ChildComponentProps>[];
}

const ChildComponent: React.FC<ChildComponentProps> = ({ a, b }) => (
  <p>
    {a} {b}
  </p>
);

const ParentComponent: React.FC<ParentComponentProps> = ({ children }) => (
  <>{children}</>
);
Enter fullscreen mode Exit fullscreen mode

It seemed like we had triumphed! We had no red squiggly lines on our code and the idea looked sound. So, we tried it out:

const Usage = () => (
  <ParentComponent>
    <ChildComponent a={1} b="First Child" />
    <ChildComponent a={2} b="Second Child" />
  </ParentComponent>
);
Enter fullscreen mode Exit fullscreen mode

This works fine. But we needed to make sure that Typescript would yell at us if we tried to give a child that wasn't a ChildComponent. And we hit a concrete wall:

const Usage = () => (
  <ParentComponent>
    <ChildComponent a={1} b="First Child" />
    <ChildComponent a={2} b="Second Child" />
    <p>I'm not a ChildComponent, this shouldn't work</p>
  </ParentComponent>
);
Enter fullscreen mode Exit fullscreen mode

Narrator: It did work

Why it worked (when it shouldn't have)

There's a very simple reason why our component did not yell at us when we passed it a child that didn't fulfill the constrain we thought we had in place. And it has to do with the type of a FunctionComponent in React.

Here we go:

FunctionComponent is:

interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
  propTypes?: WeakValidationMap<P>;
  contextTypes?: ValidationMap<any>;
  defaultProps?: Partial<P>;
  displayName?: string;
}
Enter fullscreen mode Exit fullscreen mode

We're interested in the first line of that interface definition, the one where the function that takes props is defined. So, we dive a bit deeper into what PropsWithChildren<P> is and find this:

type PropsWithChildren<P> = P & { children?: ReactNode };
Enter fullscreen mode Exit fullscreen mode

This is it. This is the aha moment. Or maybe it should've been, if we already knew how Typescript handles these cases, which we didn't at the time.

What we have here is a type extended by an intersection, where both sides of the intersection have differing definitions of a property with the same name. Remember, our P in this case was:

interface ParentComponentProps {
  children: React.ReactElement<ChildComponentProps>[];
}
Enter fullscreen mode Exit fullscreen mode

See how both the P and the inline type { children?: ReactNode} have the children property? And furthermore, they have different values!

So, how does Typescript resolve extended types where this happens? Well, it does the only thing that makes sense. It creates an union type out of them. What comes out after all this is done is:

interface FinalParentComponentProps {
  children: React.Reactelement<ChildComponentProps>[] | ReactNode;
}

// This is ReactNode btw:
type ReactNode =
  | ReactChild
  | ReactFragment
  | ReactPortal
  | boolean
  | null
  | undefined;

// And this is ReactChild
type ReactChild = ReactElement | ReactText;
Enter fullscreen mode Exit fullscreen mode

And that's it. ReactElement is fulfilled by any JSX element, like our <div>Not correct component</div> intruder up there. And this makes sense.

The React contract

Apart from any internal React explanation (there is one, but now is not the place), at the type definitions perspective, this makes sense. React's component contract is that they will render the JSX passed into HTML. And HTML will let us pass <div>s or anything else, inside anything really. Sure, sometimes it might yell at us for violating dom validations like a button inside a button, but it'll still let us do it. And so does React, letting us pass any JSX element as a child to any component that can take children. So, yeah, we learned that we can't do this at the type level. So, can we do it elsewhere?

The runtime solution

Typescript can't do it. But this is JS, where everything is possible and the points don't matter. So, we can loop through the children and check their type. Then, blow everything up if it doesn't match what we wanted. Something like this:

const ParentComponent: React.FC<ParentComponentProps> = ({ children }) => {
  children.forEach((child) => {
    if (child.type !== ChildComponent) {
      throw new Error("Only ChildComponents allowed!");
    }
  });
  return <>{children}</>;
};
Enter fullscreen mode Exit fullscreen mode

While this works... it's not ideal. We don't want our typed component to break at runtime because the person using it didn't know that it would break rules set in place by the framework itself. Let's not do that 😅.

The one that doesn't actually use children

There's another option to keep things typesafe and kind of get the end result we want... only it skips the usage of the children prop entirely. You probably already have an idea where I'm going with this:

interface ParentComponentProps {
  childrenProps: Array<ChildComponentProps>;
}

const ParentComponent: React.FC<ParentComponentProps> = ({ childrenProps }) => {
  return (
    <>
      {childrenProps.map((props) => (
        <ChildComponent {...props} />
      ))}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

This way, our component will only render ChildComponents and it will be typesafe at usage. But it bypasses the whole idea about using children 🙈.

Other options?

There's a few other things that work. Instead of throwing an error, we could ignore that element and only render the ones that fulfill the type constraint. Or, we could assert on the existence of a prop in the child instead of the type, to keep it a bit less strict while also making sure that the children contain the data we need to render them correctly. There's a lot we can do... doesn't mean we should do it.

Final words

I still believe that children are best reserved for libraries that concern themselves with wrapping components in order to enhance them. Think, CSS in JS, or stuff involving the Context api that wants to wrap things in Providers.

Does it look cool to do stuff like this?

const Usage = () => (
  <ParentComponent>
    <ChildComponent a={1} b="First Child" />
    <ChildComponent a={2} b="Second Child" />
  </ParentComponent>
);
Enter fullscreen mode Exit fullscreen mode

Sure it does. And it has its pros, like every child having their own children and making the ParentComponent's api very flexible. But the cost for this, is runtime behaviour that will need to be explained in out of code documentation and maintained fresh in the minds of any developer using this component.

Given that writing good docs is one of the hardest tasks in software, I'd say that cost is too high for most cases.

Shoutouts to Felipe who consistenly comes up with interesting ideas like this one. They usually end up in both of us learning a lot (and still disagreeing about the children property).

Top comments (0)