Trabajar con React Children

Trabajar con React Children

Cómo trabajar de forma efectiva con React Children.

En este artículo voy a tratar de explicar qué pasos se tienen que dar para lograr los siguientes dos aspectos dentro de un componente de React utilizando para ello TypeScript:

  1. Definir el tipo de datos que nos asegure de que el componente en cuestión únicamente va a aceptar como children componentes de un determinado tipo.

  2. Recorrer todos los hijos de componente haciendo que únicamente se muestren aquellos que son de un determinado tipo y añadiendo además nuevas props al mismo.

Tipado de children

La mejor de forma de entender cómo podemos tipar children dentro de un componentes en React es con un ejemplo. Supongamos que partimos del componente ComponentA y que queremos determinar que únicamente va a aceptar como children ComponentB ¿cómo lo logramos? Suponiendo que ComponentA está definido como un Functional Component nos vamos a encontrar con algo como lo siguiente:

export const ComponentA: FunctionComponent<T> = ({
  children
}): JSX.Element => ...

Está claro que el ejemplo anterior no es correcto para TypeScript pero lo que se trata de recalcar es que T es la representación del tipo de datos que recoge las props que recibe nuestro ComponentA. ¿Qué quiere esto decir? Pues que podemos definir una interfaz (o tipo) para declarar los tipos de datos que están asociados a nuestras props y utilizarla para declarar el componente. Así pues, si ahora declaramos nuestro componente como sigue:

export const ComponentA: FunctionComponent<ComponentAProps> = ({
  children
}): JSX.Element => ...

Ya solamente nos queda declarar el tipo de datos ComponentAProps y más concretamente, definir el tipo de datos que le queremos asignar a children. Pero ¿qué tipo de datos es el que le corresponde a esta prop de React? La respuesta es que React nos proporciona el tipo ReactElement para cada uno de los elementos que pueden poblar el Virtual DOM por lo que si nosotros queremos permitir que children sea de estos tipos deberíamos declarar algo como lo que sigue:

interface ComponentAProps {
  children: ReactElement<S> | Array<ReactElement<S>>
}

Es decir, estamos declarando que como children vamos a tener tanto un único elemento (que queda representado como ReactElement<S>) o varios elementos (de ahí el uso del Array, es decir, Array<ReactElement<S>>). Pero ¿estamos forzando a que estos elementos sean de un determinado tipo? La respuesta es que no, pero lo que sí que podemos jugar es con que tenemos nuevamente un tipo generíco que podemos usar al declararlo (en nuestro ejemplo S) por lo que si definimos este tipo generíco como el tipo de datos que definen a las props de los componentes hijos TypeScript ya nos indica que únicamente se permiten esos componentes hijos.

Como la explicación es liosa lo mejor es verlo siguiendo con nuestro ejemplo. Supongamos que el componente hijo que queremos definir (recordemos que es ComponentB define en la siguiente interfaz las props que admite):

interface ComponentBProps {
  // definición de las props
}

Lo que ahora podemos hacer al declarar las props de ComponentA es hacer uso de esta declaración tal y como sigue:

interface ComponentAProps {
  children: ReactElement<ComponentBProps> | Array<ReactElement<ComponentBProps>>
}

Logrando de esta manera que desde el punto de vista de TypeScript ahora mismo ComponentA solamente admita como children aquellos elementos que sean un ReactElement con las props ComponentBProps.

Nota: Aunque desde el punto de vista de TypeScript el tipado es correcto los editores de código como VSCode no van a hacer el chequeo de los tipos mientras estamos desarrollando por lo que hemos logrado tiparlo bien pero no enter reflejo en el código.

Recorrer children

¿Qué pasos tenemos que dar para recorrer todos los children que recibe un componente? Pues aquí es donde tenemos que hacer uso del método map que nos proporciona el objeto Children de React (puedes obtener más información sobre la API de Alto Nivel de React aquí). Es decir, que podemos hacer algo como lo siguiente:

import { Children } from 'react'

export const ComponentA: FunctionComponent<ComponentAProps> = ({
  children
}): JSX.Element => (
  <>
    { Children.map(....)}
  </>
)

Este método acepta dos parámetros siendo el primero de ellos la prop children (la que vamos a recorrer) y el segundo una función que se ejecutará sobre cada uno de los elementos que lo conforman. Ahora bien, ¿de qué tipo de datos es cada uno de los elementos? Pues en este caso React nos ofrece el tipo ReactNode para representarlo. Esto nos deja con la siguiente declaración:


import { Children } from 'react'

export const ComponentA: FunctionComponent<ComponentAProps> = ({
  children
}): JSX.Element => (
  <>
    { Children.map(children, (child: ReactNode) => {})}
  </>
)

¿Cómo podemos saber el tipo de datos al que pertenece cada uno de los nodos hijos? Pues aquí es donde entra en juego el saber que ReactNode tiene un atributo denominado type que contiene el tipo de datos al que pertenece el nodo. Por ejemplo, si el nodo en cuestión es del tipo ComponentB se puede hacer algo como lo siguiente:

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) {
          // .... hacer lo que sea ....
        }
    })}
  </>
)

El problema aquí es que TypeScript se va a quejar ya que no puede estar seguro de que el nodo child del ejemplo tenga el atributo type por lo que es el momento de utilizar una de las funciones de alto que nos proporciona React isValidElement la cual retorna true en el caso de que el nodo que se está procesando sea un elemento de React y por lo tanto podamos garantizar de que tiene el atributo type con los TypeScript nos dejará continuar:

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) {
            // .... hacer lo que sea ....
        }
    })}
  </>
)

Si se quiere obtener más información sobre la función isValidElement recomendamos leer la documentación oficial sobre ella.

Añadir props a los children

Como último paso lo que queremos hacer es añadir nuevas props a cada uno de los nodos children que cumplen que son del tipo ComponentB. En este caso la estrategia que vamos a seguir consiste en hacer uso de la función de Alto Nivel de React denominada cloneElement por lo que lo que queremos conseguir es una instancia igual que la que tenemos en el nodo hijo (queremos que se renderice lo mismo), pero sabiendo además que a esta función le podemos pasar un segundo atributo que tendrá un atributo por cada una de las props que vamos a inyectar. Así, en el caso de que queremos inyectar la propiedad injectedProp escribiríamos algo como lo siguiente:


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: // lo queramos inyectar (por ejemplo, una función)
            })
    }})}
  </>
)

Pero ¿cómo reflejarmos y recogemos estas props inyectadas en el ComponentB? La respuesta es haciendo eso del spread operator de JavaScript para recoger el resto de las props, lo que viene a dejarnos algo como lo siguiente:

export const ComponentB: FunctionComponent<ComponentBProps> = ({
  ...props
}): JSX.Element => ...

y de esta manera en el código de ComponentB ya podríamos acceder directament a la injectedProp como si se tratase de una de las prop que han sido declaradas en el componente.

Si se quiere obtener más información sobre la función cloneElement recomendamos leer la documentación oficial sobre ella.