In this article I will try to explain what steps have to be taken to achieve two aspects within a React component using TypeScript:
Define the data type that ensures that the component in question will only accept components of a certain type as
children
.Traverse all component children making only those that are of a certain type be shown and adding new props to it.
Type of children
The best way to understand how we can type children inside a component in React is with an example. Let's suppose that we start with the component ComponentA
and we want to determine that it is only going to accept ComponentB
as children, how can we do it? Supposing that ComponentA
is defined as a Functional Component we are going to find something like the following:
export const ComponentA: FunctionComponent<T> = ({
children
}): JSX.Element => ...
It is clear that the previous example is not correct for TypeScript but what we are trying to emphasize is that T
is the representation of the data type that collects the props that our ComponentA
receives. What does this mean? Well, we can define an interface (or type) to declare the data types that are associated to our props and use it to declare the component. So, if we now declare our component as follows:
export const ComponentA: FunctionComponent<ComponentAProps> = ({
children
}): JSX.Element => ...
Now we only have to declare the ComponentAProps
data type and more specifically, define the data type that we want to assign to children
. But what data type is the one that corresponds to this React prop? The answer is that React provides us with the ReactElement type for each of the elements that can populate the Virtual DOM so if we want to allow children to be of these types we should declare something like the following:
interface ComponentAProps {
children: ReactElement<S> | Array<ReactElement<S>>
}
That is, we are declaring that as children
we will have either a single element (which is represented as ReactElement<S>
) or several elements (hence the use of Array
, i.e. Array<ReactElement<S>>
). But are we forcing these elements to be of a certain type? The answer is no, but what we can play with is that we again have a generic type that we can use when declaring it (in our example S
) so if we define this generic type as the data type that defines the props of the child components TypeScript already tells us that only those child components are allowed.
As the explanation is complicated it is better to see it following with our example. Let's suppose that the child component that we want to define (let's remember that it is ComponentB
defines in the following interface the props that it supports):
interface ComponentBProps {
// props definition
}
What we can now do when declaring the ComponentA
props is to make use of this declaration as follows:
interface ComponentAProps {
children: ReactElement<ComponentBProps> | Array<ReactElement<ComponentBProps>>
}
Achieving in this way that from the point of view of TypeScript right now ComponentA
only admits as children
those elements that are a ReactElement
with the ComponentBProps
props.
Note: Although from TypeScript's point of view the typing is correct the code editors like VSCode will not do the type checking while we are developing so we have managed to type it right but it is not reflected in the code.
Visit children
What steps do we have to take to go through all the children
that a component receives? Well, this is where we have to make use of the map
method provided by the React Children
object (you can get more information about the React High-Level API here). That is, we can do something like the following:
import { Children } from 'react'
export const ComponentA: FunctionComponent<ComponentAProps> = ({
children
}): JSX.Element => (
<>
{ Children.map(....)}
</>
)
This method accepts two parameters, the first one being the children
prop (the one we are going to traverse) and the second one a function that will be executed on each of the elements that conform it. Now, what type of data is each of the elements? Well, in this case React offers us the ReactNode
type to represent it. This leaves us with the following declaration:
import { Children } from 'react'
export const ComponentA: FunctionComponent<ComponentAProps> = ({
children
}): JSX.Element => (
<>
{ Children.map(children, (child: ReactNode) => {})}
</>
)
How can we know the data type to which each of the child nodes belongs? Well, this is where it comes into play knowing that ReactNode
has an attribute called type
that contains the type of data to which the node belongs. For example, if the node in question is of type ComponentB
you can do something like the following:
import { Children } from 'react'
import { ComponentB } from './ComponentB'
export const ComponentA: FunctionComponent<ComponentAProps> = ({
children
}): JSX.Element => (
<>
{ Children.map(children, (child: ReactNode) => {
if (child.type === ComponentB) {
// .... do stuff ....
}
})}
</>
)
The problem here is that TypeScript is going to complain as it cannot be sure that the child
node in the example has the type
attribute so it is time to use one of the stop functions provided by React isValidElement
which returns true
in case the node being processed is a React element and therefore we can guarantee that it has the type
attribute with the TypeScript will let us continue:
import { Children } from 'react'
import { ComponentB } from './ComponentB'
export const ComponentA: FunctionComponent<ComponentAProps> = ({
children
}): JSX.Element => (
<>
{ Children.map(children, (child: ReactNode) => {
if (isValidElement(child) && child.type === ComponentB) {
// .... do stuff ....
}
})}
</>
)
If you want to learn more about the
isValidElement
function we recommend read the official documentation about it.
Add props
to the children
As a last step what we want to do is to add new props to each of the children
nodes that meet that are of type ComponentB
. In this case the strategy that we are going to follow consists of making use of the function of High Level of React called cloneElement
so what we want to obtain is an instance equal to the one that we have in the child node (we want that the same thing is rendered), but knowing in addition that to this function we can pass a second attribute that will have an attribute for each one of the props
that we are going to inject. Thus, in the case that we want to inject the injectedProp
property we would write something like the following:
import { Children } from 'react'
import { ComponentB } from './ComponentB'
export const ComponentA: FunctionComponent<ComponentAProps> = ({
children
}): JSX.Element => (
<>
{ Children.map(children, (child: ReactNode) => {
if (isValidElement(child) && child.type === ComponentB) {
return cloneElement(child, {
injectedProp: // what we want to inject it (for example, a function)
})
}
})}
</>
)
But how do we reflect and collect these props
injected into the ComponentB
? The answer is by doing that JavaScript spread operator thing to pick up the rest of the props
, which leaves us with something like the following:
export const ComponentB: FunctionComponent<ComponentBProps> = ({
...props
}): JSX.Element => ...
and this way in the code of ComponentB
we could access directly to the injectedProp
as if it was one of the prop
that have been declared in the component.
If you want to learn more about the
cloneElement
function we recommend read the official documentation about it.
Top comments (0)