iTranslated by AI

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

Unexpected Behavior of React children: Understanding How JSX is Compiled

に公開

Introduction

First, please look at the following code.

function Test({ children }: { children: ReactNode }) {
  return <>{typeof children === "string" ? <p>{children}</p> : null}</>;
}
function Test2() {
  const text = "test";
  return <Test>value:{text}</Test>;
}

When the Test2 component in this code is called, what will appear on the screen?
The answer is: nothing will be displayed.
In this article, we will explore why this behavior occurs.
As a note, this article focuses purely on the output results.
Please understand that it does not cover the internal reasons for why React behaves this way.
Now, let's begin.

What is JSX?

If we look at the React documentation, we find the following description:

JSX is a syntax extension for JavaScript that lets you write HTML-like markup inside a JavaScript file.

In React, we write HTML-like elements as if it's the natural thing to do, but it's actually quite interesting when you think about it.
JSX is what handles that wonder for us.
Thanks to JSX, we can define our UI using HTML-like syntax within JavaScript code.
Therefore, the official React documentation also recommends describing components using JSX.
By the way, as stated in the React documentation, JSX itself is not a feature of React.
While React uses JSX to describe elements, JSX is not limited to React.

Looking at Compilation Results

We've taken a very brief look at JSX.
Since JSX is very similar to writing HTML, it feels as though what you write in a component is displayed exactly as is.
However, since JSX is actually an extension of JavaScript, it is converted into a format that can be rendered on the screen during compilation.
So, let's look at how it is actually converted.
To do that, we'll first create a React project.
You can use any method to create a React project, but this time I'm using Vite.
After installing Vite and creating a React project with npm create vite@latest, import the following modules:

npm install --save-dev @babel/core @babel/preset-react

After importing, change vite.config.js (or .ts) as follows:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react({
      jsxRuntime: "classic",
      babel: {
        plugins: [
          [
            "@babel/plugin-transform-react-jsx",
            {
              pragma: "React.createElement",
              pragmaFrag: "React.Fragment",
            },
          ],
        ],
      },
    }),
  ],
});

Now that the preparation for compilation is complete, let's actually run it.
First, set up App.jsx (or .tsx) as follows:

function App() {
  return <p>テスト</p>;
}
export default App;

Next, run npm run build.
A JavaScript file will be created under the dist/assets directory.
If you open that .js file and look towards the end after formatting, you should find a description like this:

function wd() {
  return React.createElement("p", null, "テスト");
}

This is the process that renders the component created earlier using JSX syntax.
Checking the documentation for React.createElement, you'll find the following description and definition:

createElement lets you create a React element. It serves as an alternative to writing JSX.

const element = createElement(type, props, ...children);

It seems this is the way to describe screen rendering in React without using JSX syntax.
Now that we've confirmed the compilation result becomes React.createElement, let's look at the arguments.

type

This specifies an HTML tag or a React-specific component (e.g., Fragment).

props

This is the object passed to the component.
Since it is an object, it is passed like this:

function Greeting({ name }) {
  return createElement("h1", { className: "greeting" });
}

children

This is the child node part of the component you are creating.
You set this similarly to children in JSX.
For example, suppose you have the following in JSX:

function Greeting({ name }) {
  return (
    <h1 className="greeting">
      Hello <i>{name}</i>. Welcome!
    </h1>
  );
}

Rewriting this using React.createElement looks like this:

function Greeting({ name }) {
  return createElement(
    "h1",
    { className: "greeting" },
    "Hello ",
    createElement("i", null, name),
    ". Welcome!"
  );
}

As you can see from ...children in the React.createElement definition, you can set multiple arguments.
Therefore, if there are multiple items you want to render, you can set the values as the third and subsequent arguments in the order you want them displayed.
We have now reviewed the compilation results and the elements used within them.
Now, let's look at the reason why nothing is displayed when using the following Test2 component, which is the main topic.

function Test({ children }: { children: ReactNode }) {
  return <>{typeof children === "string" ? <p>{children}</p> : null}</>;
}
function Test2() {
  const text = "test";
  return <Test>value:{text}</Test>;
}

Why typeof children does not become a string

From here, we will look at the reason why typeof children === 'string' did not result in true. However, before looking at the main code, let's first check the compilation result of the following code.

function Test2() {
  const text = "test";
  return <p>value:{text}</p>;
}

Compiling this code results in the following:

function wd() {
  return React.createElement("p", null, "value:", "test");
}

In the children part of React.createElement, "value:", which is written directly in JSX, and the value of the variable text are set separately. Next, let's compile the following code.

import { ReactNode } from "react";
function Test({ children }: { children: ReactNode }) {
  return <p>{children}</p>;
}
function Test2() {
  const text = "test";
  return <Test>value:{text}</Test>;
}

At that time, the part that performs screen rendering looks like this:

function wd({ children: e }) {
  return React.createElement("p", null, e);
}
function Sd() {
  return React.createElement(wd, null, "value:", "test");
}

The function wd, which corresponds to the rendering of the Test component, is called within the Sd function (corresponding to the Test2 component) using React.createElement.
At that point, two separate arguments, "value:" and "test", are set for the children portion.
Now, let's check the definition of createElement once more.

createElement(type, props, ...children);

The children part uses the rest parameter syntax.
Rest parameters receive the corresponding arguments as an array.
For example, look at the following code:

const test = (...args) => {
  console.log(args);
};
test(1, 2, 3, 4);

In this case, the output value is [1, 2, 3, 4].
From this, we can see that the value passed to the Test component becomes ['value:', 'test'].
Since the received value is ['value:', 'test'], when trying to determine the type with typeof, it becomes object instead of string.
This is the reason why the element is not displayed in the code shown initially.
While it feels natural that typeof children === 'string' does not result in true once you trace the process, I felt that writing it in JSX makes the behavior harder to understand.
Nonetheless, I'm glad I was able to understand the behavior of children.
Therefore, I will be careful when using typeof within React and how I interpret children in the future.

Wanting to use string evaluation even in cases like this

Up until now, we have looked at why the behavior of typeof differs from what might be expected when children contains a mix of dynamically set strings and literal strings.
Even if the behavior is understood, I personally feel it is difficult to use while anticipating cases where it returns false despite essentially only strings being provided.
Therefore, here we will make it possible to judge the case as true even for the code from the Test2 case introduced earlier.

function Test2() {
  const text = "test";
  return <Test>value:{text}</Test>;
}

To achieve this, we use Children. This Children is not the children included in props, but a React feature that can be used to manipulate children.
Specifically, we will use Children.toArray.
This stores the received children into an array.
While children is received as an array if there are two or more values passed to the children argument of React.createElement, it is not an array if there is only one.
Therefore, to ensure that children can be evaluated as an array regardless of how it is passed, we use Children.toArray to make children an array.
After that, if we check whether all elements in the arrayized children are strings, it will perform the expected behavior.
Specifically, the code is as follows:

import { Children, ReactNode } from "react";
function Test({ children }: { children: ReactNode }) {
  const childrenElms = Children.toArray(children);
  const isOnlyStr = childrenElms.every((child) => typeof child === "string");
  return <>{isOnlyStr ? <p>{children}</p> : null}</>;
}
function Test2() {
  const text = "test";
  return <Test>value:{text}</Test>;
}

By doing this, "value:test" is now rendered on the screen, whereas nothing was displayed before.
If you want to process whatever is passed to children as a string as long as the content itself consists of strings, you can use Children.toArray for the evaluation.
However, as mentioned at the beginning of the documentation, the use of Children is not recommended.
Therefore, it would likely be better to replace it with another method, such as changing the evaluation logic itself, rather than using Children whenever possible.

Side Note: The import React from 'react' Boilerplate That Disappeared Before We Knew It

A while ago, when implementing with React, it was mandatory to include the import React from 'react' boilerplate in every file. If you didn't write this, an error would occur as shown below, so I used to write it without really knowing the reason why.

image.png

I understood the reason for this while investigating the compilation process this time. In older versions of React, the code for the rendering part would be output like this after compilation:

function wd({ children: e, text: n }) {
  return React.createElement("p", null, e, n);
}
function Sd() {
  return React.createElement(wd, null, "value:", "test");
}
Za(document.getElementById("root")).render(
  React.createElement(Xi.StrictMode, null, React.createElement(Sd, null))
);

As you can see, the process naturally uses the React object. This was generated regardless of whether the React object was actually imported. Because of this generated code, running the app without the boilerplate would trigger the error mentioned earlier. Therefore, we always had to include import React from 'react' to avoid the error, even if we weren't explicitly using it.

However, at some point, it started working without that boilerplate. According to this article, the reason seems to be that the compilation results changed starting from React 17.

What previously used React.createElement was changed to rendering via the jsx function. To confirm this change, let's check what happens to the part that was previously React.createElement in React version 18.3.1.

The compilation result is as follows:

function Ld({ children: e }) {
  return zr.jsx("p", { children: e });
}
function Td() {
  return zr.jsxs(Ld, { children: ["value:", "test"] });
}
ec(document.getElementById("root")).render(
  zr.jsx(Vu.StrictMode, { children: zr.jsx(Td, {}) })
);

It is now being rendered using jsx. With this change in compilation output, the React object is no longer used, and it now works without the import React from 'react' boilerplate.

It's quite interesting. Note that only the compilation result has changed; the way developers write React hasn't changed significantly. Therefore, the behavior and handling of children that we've discussed remain the same. Additionally, to output the compilation results shown earlier in this article, I installed @babel/core and @babel/preset-react and configured the Vite plugin. These settings were specifically to output the old compilation results, so there is generally no need for current React developers to apply them. In fact, unless you have a specific reason, it's better not to.

Conclusion

In this article, starting from a behavior of children that I found strange, we examined the compilation results and explored potential solutions.
Since JSX uses an HTML-like syntax, I find it very convenient for general development.
However, because I've become so accustomed to the simplicity of JSX, I struggled to understand how it would behave in a case like this.
Through research, I was able to resolve the confusion to some extent, but it made me realize that my understanding of React is still quite superficial.
I hope to continue learning more.
Thank you for reading this far.

References

https://react.dev/reference/react/createElement
https://zenn.dev/uhyo/articles/react17-new-jsx-transform#新しい jsx の変換結果
https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html

Discussion