The fullstack lifecycle of a React component

Component based frameworks like React, Vue or Angular allow us to think about our components in isolation. We don't have to worry about how our code gets bundled, compiled and executed in the user's browser, because the component itself is completely decoupled from that. However, there are situations in which knowledge about the complete lifecycle of a component is beneficial. In this article, I want to lead you through this complete lifecycle of a React component, starting with the creation of the component's code, static analysis of this code, the first usage, bundling it before execution, rendering on the server and finally the execution on the client.

Writing your first Component

Let's jump right into it and write our first simple React Component:

import React, { useEffect, useState } from "react";

export function CounterButton(props: { initialCount: number }) {
  const [count, setCount] = useState(props.initialCount);

  useEffect(() => {
    console.log("Initial count: " + props.initialCount);
  }, [props.initialCount]);

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

As you already know, React components are plain JavaScript/TypeScript functions, that take one argument: "props". This props object contains all data and configuration that is needed to render our component. In our case, we added the TypeScript type definition { initialCount: number }, so that we and other developers know, how to use this component.

Static checks with TypeScript and ESLint

As soon as you type this into your IDE or hit save, dependening on your setup, many tools already take a look at this code and try to interpret it: ESLint might check if you used the useState hook correctly and not conditionally or in a loop, Prettier might reformat your code so that it looks according to your guidelines and TypeScript checks if you used all typed functions correctly, defined all required imports and passed the right props to the built-in button host component "button". All of this happens, before your code even runs once and helps to reduce the feedback loop of type code, check results, fix errors, check results and be proud.

Using your component

Now, we have written our first component! However, nothing will happen with this code, if nobody uses it. So let's do just that:

import React from "react";
import { CounterButton } from "./counter-button";

export function App() {
  return (
    <div>
      <CounterButton initialCount={0} />
      <CounterButton initialCount={999} />
    </div>
  );
}

Of course, we use our React component in another React component in another file. As before, as soon as we write/save this file, many tools analyze it, check conventions, check if we passed the correct props to our CounterButton component — all without running the component itself.

Bundling your app

Sadly, not all browsers support this modular way of writing code in separated files. This is why we need bundlers like Webpack, Parcel or rollup. You simply tell these tools which file your entry point is, and they crawl all those import statements and produce one or few resulting files, that can be loaded by the browser in a more performant way.

Additionally, these bundlers have the ability to transform your code. In our example, we use TypeScript to annotate our code with information about the types. Browsers don't understand these types, which is why our build chain (orchestrated by the bundler) removes all those type annotations. On top of that, React uses a language extension called JSX or TSX when writing TypeScript which allows us to write these weird HTML-like tags directly in our code. Again, browsers don't support these, so we have to get rid of them by transforming each tag into a call to React.createElement. This is the result of these transformations when using Parcel to bundle only our CounterButton:

const react_1 = __importStar(require("react"));

function CounterButton(props) {
  const [count, setCount] = react_1.useState(props.initialCount);
  react_1.useEffect(() => {
    console.log("Initial count: " + props.initialCount);
  }, [props.initialCount]);
  return react_1.default.createElement(
    "button",
    {
      onClick: () => setCount(count + 1),
    },
    "Count: ",
    count
  );
}

exports.CounterButton = CounterButton;

Do note that I omitted all of React's library code as well as the bundler's setup code from the resulting file. In case of a production app, your bundler would even go one step further. Since those variable names and whitespaces all take precious bytes that have to be transferred to the user, bundlers optimise that by using a minifier or uglyfier.

Rendering your app on the server (SSR)

After we bundled our code, it is time for it to run for the first time. In many cases, we do not want our code to only run on the client. In order for faster loading times and better SEO/Google-results, we generate real HTML on the server, that we can then send to the client. For this case, we have to create an entry point that is being run on the server: server.tsx

import express from "express";
import fs from "fs";
import React from "react";
import { renderToString } from "react-dom/server";
import { App } from "./App";

const app = express();

app.get("/", (req, res) => {
  const reactString = renderToString(<App />);
  res.send(`
    <!DOCTYPE html>
    <html>
      <head></head>
      <body>
        <div id="root">${reactString}</div>
        <script src="client.js"></script>
      </body>
    </html>
  `);
});

app.get("/client.js", (req, res) => {
  fs.createReadStream("./dist/client.js").pipe(res);
});

app.listen(8080);

This file is executed on the server and starts a web server on port 8080. Whenever we receive a get request at /, we call React's renderToString method, which calls all React component functions, passes props down to the child components, initialises the state variables with the initial values (0 and 999) and then builds the resulting HTML string. It is very important to note, that none of React's lifecycle methods are called during this server side rendering process. So you can use useEffect, useLayoutEffect or componentDidMount in your components but they will be ignored when creating this initial HTML string. In case you need any async data for the server side rendering process, you have to load it before calling renderToString, as side effects (in useEffect) will not be called on the server. (componentWillMount will be called, but is deprecated.). That means, our console.log from the useEffect hook, will not be called at this time.

Additionally we configure our web server so that requests to /client.js get the respective client JavaScript file which we will show next.

Interested in React development? Follow me on Twitter so you don't miss new content.

When we now open the browser on http://localhost:8080, we get the following HTML document back:

<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <div id="root">
      <div data-reactroot="">
        <button>Count: 0</button>
        <button>Count: 999</button>
      </div>
    </div>
    <script src="client.js"></script>
  </body>
</html>

Hydrating on the client

As mentioned before, until now, all JavaScript was executed on the server. We started the web server and for each request, we call all React components (without calling their lifecycle methods and effects) and generate the raw HTML representation.

The next step is to create a client bundle, that will be executed in the user's browser. The goal is to take over the server generated HTML. This can be done by using React's hydrate function in a new file client.tsx:

import React from "react";
import { hydrate } from "react-dom";
import { App } from "./App";

const appRoot = document.getElementById("root");

hydrate(<App />, appRoot);

Again, we have to tell our bundler to build this file, which will remove types, bundle all files together and remove JSX/TSX. By using hydrate we basically tell React to skip generation of HTML elements on the initial render (because they are already present in the HTML document). When the user now visits http://localhost:8080, the HTML is generated in your node app, loaded by the browser and displayed to the user. The browser parses this document, until it reaches the script tag. This instructs the browser to request this file from our server. When this is loaded and parsed, execution of the client side bundle starts with our hydrate call. Note that during that, the page is already filled with content from the HTML file, so that the user does not have to stare at a blank page.

By this call to hydrate, we instruct React to create a virtual representation of the DOM by calling all our component functions. In case of the initial render this v-DOM should always match the DOM sent by the server. Additionally, React attaches all defined event listeners (like our click handlers on the buttons) to the existing DOM nodes (this is a simplification of what really happens with event listeners but is good enough for this article). After this process is complete, our effects are called so that we can fetch data, subscribe to data sources or execute any other side effect.

I want to emphasise one point of this process: The user can already see the content of the page, long before React has been loaded, initialised and is ready to react to user interactions. That means that this first version of the page has to be as usable as possible. Any updates to the content triggered by a useEffect call will cause jarring jumps in the UI.

As you can see, React has to do quite a bit of work in order to deliver a first fully working web application. This is why it is so important to prerender the DOM on the server (or during your build process when using a static site generator like Gatsby.js) when dealing with content heavy use cases like a blog or a news article.

Updating state

As said before, our application is now ready for interaction. Let's say the user now clicks on the button in our first CounterButton component. This triggers our event listener in the respective button component instance (yes, even when using function components, there exist instances of our components) which in turn calls the stateSetter. This instructs React to restart the rendering process from this component instance all the way down the tree (or until React encounters a memoized component with no prop changes). A new version of the v-DOM is created, compared to the previous version, patches to the DOM are generated and then "commited". After React changed the DOM so that the clicked button now shows an increased number, React calls all effects, where at least one dependency changed. When one of these effects triggers another setState call, the process restarts from the beginning.

The user now sees the app in the new state: A 2 on the first CounterButton and the effect of the button logged the next number to the console. The app is now once again waiting for interaction.

Cleaning up for unmount

In our case, components never unmount, so that we never have to do any cleanup. However, let's imagine that this application is only on one route in a large single page application. What happens now, when we navigate to another route?

  1. React first creates the new the virtual DOM of the next page (no real DOM nodes have been touched yet).
  2. React now checks which component instances are no longer needed and calls their effect's cleanup function.
  3. After the cleanup function has been called, the respective DOM nodes are removed from the DOM.

The complete journey

This is it. The complete journey of a React component. Of course we could take this even one step further and also cover the steps of refactoring a React component but this is a topic for another article.