iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🧩

Expanding Component Expressiveness with Functions as Children

に公開

Introduction

Did you know that you can receive React children as a function?
By leveraging this, you can enhance the expressiveness of your components.

app.tsx
import { FC, ReactNode } from 'react'

export const App: FC = () => {
  return (
    <Component>
      {() => <h1>Hello</h1>} {/* This is the children */}
    </Component>
  )
}

const Component: FC<{ children:() => ReactNode }> = ({ children }) => {
  return <div>{children()}</div>
}

The expression passed from the parent component is executed within the template section of the child component, and the function's return value is rendered.
There is no black magic involved!
In TypeScript, by setting the type of children to () => ReactNode, it ensures that a type error will occur if you don't pass a function inside the {}.

Examples of children as a function

  • <Context.Consumer> in the Context API

In the React Context API, the child element of <Context.Consumer> is a function.

Context.Consumer
import { FC, createContext } from 'react'

const Context = createContext('default')

export const App: FC = () => {
  return (
    <Context.Provider value="value">
      <Context.Consumer>
        {(value) => <h1>{value}</h1>}
      </Context.Consumer>
    </Context.Provider>
  )
}

The official documentation also states that the child element of <Context.Consumer> is a function.
Using Context.Consumer allows you to directly reference the Context value (e.g., value in the example) within the template, and the scope is contained within the children.
Nowadays, with the introduction of the useContext hook, the benefit of using <Context.Consumer> has diminished. However, the ability to encapsulate the scope of the value variable within an expression inside children is still useful.
This demonstrates that there is no issue with children being a function.

What are the benefits?

Passing children as a function allows the parent component to freely control the internal behavior and state of the child component.
For example, you can display different UIs depending on the state or update the UI based on dynamically generated data.
If you were to attempt to access the interior of a child component without using children(prop), you would need to use additional APIs such as the Context API or useImperativeHandle.
This would unnecessarily increase the number of global variables and the amount of code required to support them.
Furthermore, since those are hooks, they force the use of Client Components, whereas children(prop) alone allows you to maintain Server Components. (The examples described later are both SC.)
This means you can implement the boundary between Server and Client closer to the Server side, which offers better performance.
In other words, polymorphic expressions like the following become possible while narrowing the scope of variables within children:

  • Customizing the content from the parent side without exposing the state to the parent component.
  • Using generics to return processed values based on the type of the passed prop.

In fact, these techniques are frequently used in libraries.

Implementation Examples

checkbox.tsx
import { ReactNode, useCallback, useState } from "react";

export const Checkbox: FC<{
  children: FC<{ isChecked: boolean; handleToggle: () => void }> | ReactNode;
}> = ({ children }) => {
  const id = useId();
  const [isChecked, setChecked] = useState(false);
  const handleToggle = useCallback(() => setChecked((prev) => !prev), [isChecked]);
  return (
    <label htmlFor={id}>
      <input id={id} type="checkbox" checked={isChecked} className="sr-only" />
      {typeof children === "function" ? children({ isChecked, handleToggle }) : children}
    </label>
  );
};
confirmation.tsx
import { Checkbox } from "./checkbox";
import { Check, X } from "./icons";

export const Confirmation: FC = () => {
  return (
    <Checkbox>
      {({ isChecked, handleToggle }) => (
        <div>
          {isChecked ? <Check /> : <X />}
          <button onClick={handleToggle}>Confirm</button>
        </div>
      )}
    </Checkbox>
  );
};

This is a component that adds checkbox functionality to the component passed as a child.
It encapsulates state management within the <Checkbox> component and uses children to allow the state to be referenced.
It branches using typeof children === "function" to account for cases where children might not be a function.
This allows you to pass child elements in the traditional way when there is no need to access the state.

fieldset.tsx
import { FC, ReactNode } from "react";
import { FieldMetadata, getFieldsetProps } from "@conform-to/react";

type FieldsetProps<T extends FieldMetadata<Record<string, any> | undefined, any, any>> = {
  field: T;
  children: ({ field: ReturnType<T["getFieldset"]> }) => ReactNode;
} & Omit<ComponentProps<"fieldset">, "children">

export const Fieldset = <T extends FieldMetadata<Record<string, any> | undefined, any, any>>({
  field,
  children,
  ...props
}: FieldsetProps<T>) => {
  return (
    <fieldset {...props} {...getFieldsetProps(field)}>
      {children({ field: field.getFieldset() })}
    </fieldset>
  );
};

This is a component that wraps form fields using Conform's FieldMetadata.
In Conform, it is convenient to retrieve nested fields within a field object using method chaining. However, when building complex input forms, you often end up either repeating the same method chains multiple times or declaring a large number of variables that are only used within the template section.
When a component becomes complex, one would normally consider splitting it. However, splitting components that share a common purpose, like a form, can decrease cohesion and reduce readability.
By using the Fieldset component as shown in the example below, you can narrow the scope of variables within children, enabling you to declare variables with the same name within that scope.

fields.tsx
import { FC } from "react";
import { Fieldset } from "./fieldset";
import { TextField, SelectField } from "./form";
import type { FormSchema } from "./schema";
import { useForm } from "@conform-to/react";

export const Fields: FC<{ field: ReturnType<(typeof useForm<FormSchema>)["field"]> }> = ({
  field,
}) => {
  return (
    <>
      <TextField name={field.userName.name} label="Username" />
      <TextField name={field.email.name} label="Email Address" />
      <Fieldset field={field.address}>
        {({ field }) => (
          <>
            <TextField name={field.postalCode.name} label="Postal Code" />
            <TextField name={field.city.name} label="City" />
            <TextField name={field.street.name} label="Street" />
            <TextField name={field.building.name} label="Building Name" />
          </>
        )}
      </Fieldset>
      <Fieldset field={field.phone}>
        {({ field }) => (
          <>
            <TextField name={field.number.name} label="Phone Number" />
            <SelectField name={field.type.name} label="Type" options={["Mobile", "Landline"]} />
          </>
        )}
      </Fieldset>
    </>
  );
};

FormSchema has the following JSON structure:

schema.ts
export type FormSchema = {
    userName: string,
    email: string,
    address: {
        postalCode: string,
        city: string,
        street: string
        building: string
    },
    phone: {
        number: string,
        type: "Mobile" | "Landline"
    }
}

By representing this structure visually using the Fieldset and Field components, we were able to create a highly readable form component.

Summary

By passing children as a function, you can improve component reusability and extensibility, making UI design more flexible.
Especially for components with high cohesion (those focused on a single purpose), it allows you to narrow the scope and improve code readability.
I encourage you to try utilizing this technique in your complex components and UI designs.

GitHubで編集を提案

Discussion