Minimal React: getting started with the frontend library

[2020-08-25] dev, javascript, frontend, react
(Ad, please don’t block)

This blog post explains how to get started with React while using as few libraries as possible.

Required knowledge  

Things you should know before reading this blog post:

  • JavaScript: You should have already written code in that language.
  • Browser DOM (document object model): It helps if you are loosely familiar with how the DOM represents HTML and how it handles events.
  • npm: It also helps if you have a basic understanding of the npm package manager for Node.js.

About this blog post  

Many tutorials provide comprehensive introductions to the React ecosystem. I wanted to try something different:

What is the smallest set of libraries that allows you to be productive in React?

This is an exhaustive list of the npm packages that the code in this blog post depends on:

The repository  

The repository minimal-react contains the examples that we are exploring in this blog post:

  • You can try out the examples online.
  • You can install it locally to play with the complete setup. Everything is installed inside a single directory, so it’s easy to remove later on.
  • However, installing the repository is not required for following this blog post. All relevant data is quoted inside the post.

The repository has the following structure:

  • minimal-react/
    • html/: HTML files
    • js/: JavaScript code
    • README.md: Instructions for installing and running the project
    • package.json: Configuring the npm package manager
    • snowpack.config.json: Configuring the Snowpack build tool

package.json specifies the npm packages that the JavaScript code depends on:

"devDependencies": {
  "@snowpack/plugin-react-refresh": "^2.1.0",
  "snowpack": "^2.9.0"
},
"dependencies": {
  "htm": "^3.0.4",
  "immer": "^7.0.7",
  "react": "^16.13.1",
  "react-dom": "^16.13.1"
}

package.json also defines two scripts:

"scripts": {
  "start": "snowpack dev",
  "build": "snowpack build"
},

These are executed via:

  • Starting the development web server: npm run start
    • Abbreviated: npm start
  • Creating a standalone application (that runs without the development server): npm run build

What is React?  

React is a library for creating user interfaces in web browsers. Before we take a look at how it works, let us remind ourselves how user interfaces are created if they are based on a traditional model-view-controller approach.

The traditional model-view-controller (MVC) approach  

This object-oriented approach gets its name from three roles that objects play in it:

  • The model is the data to be accessed via the graphical user interface.
  • The view displays the model.
  • The controller reacts to events (actions by the user) and updates model and view accordingly.

Traditional MVC-based user interfaces work as follows:

  • A tree of user interface components is created once.
  • Each user interface component manages its own state and updates it incrementally, in response to user interactions.
  • “Glue code” that is external to the user interface components, propagates state changes between them.

This approach has downsides:

  • The user interface logic is often scattered across the code.
  • Cross-component changes are difficult to implement.
  • It’s easy to introduce inconsistencies because there can be many different combinations of states.

React  

React works differently:

  • The user interface is encoded as a tree-shaped data structure. It is called virtual DOM, due to its similarity with the document object model (DOM) used by browsers to represent HTML.
  • There is a single (nested) model for the complete user interface.
  • A user interface component is simply a function that maps a model to a user interface.
    • The root component has as input the whole model and passes on parts of that model to subcomponents (which are also functions).
  • When the user interacts with the user interface, the model is changed accordingly and the complete user interface is recreated (by invoking the root component again).
    • To make this viable, performance-wise, React compares the virtual DOM returned by the root component with the current browser DOM. It only changes the latter where the former differs.

Benefits of this approach:

  • It’s easier to understand the user interface logic.
  • Cross-component dependencies are easier to implement.
  • The data flow is simpler: always from the top of the user interface component tree to its bottom.

First example: counting clicks  

The first example is in the file minimal-react/html/counting-clicks.html.

Adding the user interface to the HTML page  

This is the body of the HTML page:

<h1>Counting clicks</h1>
<div id="root"></div>
<script type="module" src="../js/counting-clicks.js"></script>

This is how minimal-react/js/counting-clicks.js adds its user interface to the web page:

import ReactDOM from 'react-dom';
import {html} from 'htm/react';
import {useState} from 'react';

// ···

ReactDOM.render(
  html`<${CountingClicks} rootModel=${rootModel} />`, // (A)
  document.getElementById('root')); // (B)
  • Line A is how we create user interface elements (via the virtual DOM). Read on for more information.
  • Line B is the HTML element in which React creates the user interface.

Creating user interface elements  

Consider the following syntax from the previous example:

html`<${CountingClicks} rootModel=${rootModel} />`

There are two layers to this syntax.

Syntactic layer 1: tagged templates  

html`···` is a tagged template. Tagged templates are a JavaScript language feature that lets us embed foreign syntax in JavaScript code. Each tagged template is actually a function call – for example:

const numberOfFruits = 4;
const nameOfFruits = 'strawberries';
const result = someFunc`I have ${numberOfFruits} ${nameOfFruits}!`;

The the last line is equivalent to:

const result = someFunc(['I have ', ' ', '!'], numberOfFruits, nameOfFruits);

Tag functions such as someFunc() can return arbitrary values and are usually guided by their input. In this case, the input is:

  • The template strings ['I have ', ' ', '!'] are static (the same each time this particular function call is made)
  • The substitutions numberOfFruits and nameOfFruits are dynamic (possibly different each time this particular function call is made)

Substitutions are inserted “into” the template via the syntax ${···}.

The tag function html supports React’s syntax for creating virtual DOM elements. It parses its input to produce its output.

Syntactic layer 2: JSX, React’s syntax for creating virtual DOM elements  

JSX is a non-standard JavaScript language feature introduced by React. It lets us use HTML-ish expressions to create virtual DOM data. JSX must be compiled to standard JavaScript and is supported by several compilers – for example:

  • Babel:
    • Input: modern and/or future JavaScript
    • Output: current or older JavaScript
  • TypeScript:
    • Input: JavaScript plus static type information (roughly, a superset of JavaScript)
    • Output: current or older JavaScript

In this tutorial, we use a tagged template instead of JSX, which has the benefit that we can use plain JavaScript (no compilation is necessary). There are only minor differences between html syntax and JSX, which is why I’ll occasionally use the name JSX for the former.

There are two kinds of elements.

React components  

First, the name of an element can be a function whose name starts with an uppercase letter:

html`<${UiComponent} arg1="abc" arg2=${123} />`

This expression is equivalent to:

React.createElement(UiComponent, { arg1: "abc", arg2: 123 })

In this case, React.createElement() makes the following function call:

UiComponent({ arg1: "abc", arg2: 123 })
Virtual DOM elements  

Second, the name of an element can also be a string that starts with a lowercase letter:

html`<div arg1="abc" arg2=${123} />`

This expression is equivalent to:

React.createElement("div", { arg1: "abc", arg2: 123 })

In this case, React.createElement() directly creates virtual DOM data.

JSX in action  

Let’s go back to the initial code:

html`<${CountingClicks} rootModel=${rootModel} />`

What is happening here?

We are invoking the component CountingClicks (a function) and pass it a single parameter, whose label is rootModel. This is what the root model looks like:

const rootModel = {
  numberOfClicks: 0,
};

The component CountingClicks()  

The component is implemented as follows:

function CountingClicks({rootModel: initialRootModel}) {
  const [rootModel, setRootModel] = useState(initialRootModel); // (A)
  return html`
    <div>
      <a href="" onClick=${handleIncrement}>
        Number of clicks: ${rootModel.numberOfClicks}</a>
      <p />
      <button onClick=${handleReset}>Reset</button>
    </div>
  `;

  function handleIncrement(event) {
    // ···
  }
  function handleReset(event) {
    // ···
  }
}

The component returns a single virtual DOM element, a <div>. We use the ${···} syntax to insert values into the returned data:

  • The click event handler handleIncrement
  • The number rootModel.numberOfClicks
  • The click event handler handleReset

Handling state via the useState hook  

The function call useState() in line A adds reactivity to our code:

  • rootModel is the current model data (the M in MVC).
  • initialRootModel is the initial value of rootModel.
  • setRootModel can be used to change rootModel. Whenever we do that, React automatically reruns the CountingClicks component so that the user interface always reflects what’s in the model.

Never mind how exactly React does this! There is a ton of magic going on behind the scenes. Therefore, it is better to think of useState() as a language mechanism rather than as a function call. useState() and other similar functions are called hooks because they let us hook into React’s API.

Handling click events  

Clicks on the <a> element are handled by the following function:

function handleIncrement(event) {
  event.preventDefault(); // (A)
  const nextRootModel = { // (B)
    numberOfClicks: rootModel.numberOfClicks + 1,
  };
  setRootModel(nextRootModel); // (C)
}

If a user clicks on an <a> element that has the attribute href, then, by default, the browser goes to the location specified by the attribute. The method call in line A prevents that from happening.

In line B, we create a new root model. We don’t change the existing model, we create a modified copy of it. Non-destructively updating data is a best practice in React. It avoids several problems.

In line C, we use the setter created by the useState() hook to make nextRootModel the new root model. As mentioned before, setRootModel() will also recreate the complete user interface by invoking CountingClicks() again.

Clicks on the <button> are handled by the following function:

function handleReset(event) {
  const nextRootModel = {
    numberOfClicks: 0,
  };
  setRootModel(nextRootModel);
}

This time, we don’t need to prevent a default action. We again create a new root model and activate it via setRootModel().

Second example: expandable sections  

The second example is in the file minimal-react/html/expandable-sections.html.

Entry point  

This time, the entry point of the JavaScript code looks like this:

ReactDOM.render(
  html`<${Sections} sections=${addUiProperties(sections)} />`,
  document.getElementById('root'));

The initial root model is:

const sections = [
  {
    title: 'Introduction',
    body: 'In this section, we are taking a first look at the ideas are covered by this document.',
  },
  // ···
];

Function addUiProperties() adds a single user-interface-related property to the root model:

function addUiProperties(sections) {
  return sections.map((section) => ({
    ...section,
    expanded: false,
  }));
}

We use spreading (...) to copy each element of the Array sections while adding the new property expanded. Once again, we are not modifying the original data, we are updating it non-destructively.

User interface component Sections()  

This is the root user interface component of the current example:

function Sections({sections: initialSections}) {
  const [sections, setSections] = useState(initialSections);
  return sections.map((section, index) => html`
    <!--(A)-->
    <${Section} key=${index}
      sections=${sections} setSections=${setSections}
      section=${section} sectionIndex=${index} />
  `);
}

We again use the useState() hook to manage the model.

This time, the component returns an Array of virtual DOM elements (that are created by the subcomponent Section()). Note the key attribute in line A. Whenever we use an Array as virtual DOM data, each of the elements must have a unique key. The idea is that React can more efficiently update the browser’s DOM if each Array element has a unique identity. For example, if we only rearrange the elements but don’t otherwise change them, then React only needs to rearrange browser DOM nodes.

User interface component Section()  

This is the component for a single section:

function Section({sections, setSections, section, sectionIndex}) {
  return html`
    <div style=${{marginBottom: '1em'}}> <!--(A)-->
      <h3>
        <a href="" style=${{textDecoration: 'none'}} onClick=${handleClick.bind(undefined, sectionIndex)}> <!--(B)-->
          ${section.expanded ? '▼ ' : '▶︎ '} <!--(C)-->
          ${section.title}
        </a>
      </h3>
      ${
        !section.expanded // (D)
        ? null
        : html`
          <div>
            ${section.body}
          </div>
        `
      }
    </div>
  `;

  function handleClick(sectionIndex, event) { // (E)
    event.preventDefault();
    setSections(expandExactlyOneSection(sections, sectionIndex));
  }
}

Using CSS in React components  

In line A, we are specifying CSS via an object literal:

  • CSS property names such as margin-bottom are translated to JavaScript identifiers such as marginBottom.
  • CSS property values are specified via strings.

React’s rules for whitespace  

In line C, we are using ${···} to insert a string into the user interface. JSX handles whitespace differently from HTML: Whitespace between lines is completely ignored. That’s why there is a space after each triangle.

Why does JSX do that? We can see the benefit in line B: The opening <a> tag can be on its own line and no space is inserted between that tag and the text in the next line.

Conditional evaluation  

In line D, we are evaluating a condition:

  • If the condition is true, we don’t insert anything into the user interface (as indicated by the special value null).
  • If the condition is false, we insert virtual DOM data into the user interface.

Handling clicks  

In line E, we are dealing with clicks on the triangle:

  • First, we prevent the browser’s default action.
  • Next, we change the model of the root component via setSections() (which was passed to Section() via a parameter). That leads to the user interface being re-rendered.

Function expandExactlyOneSection() non-destructively updates sections so that only the section is expanded whose index is sectionIndex.

Exercises  

  • Add numbers to the sections.
  • Change the code so that more than one section can be open at the same time.
    • You’ll need to change and rename expandExactlyOneSection().

Third example: quiz  

The third example is in the file minimal-react/html/quiz.html.

The model  

This is the data that encodes quiz entries. Each entry has a question and zero or more answers:

const entries = [
  {
    question: 'When was JavaScript created?',
    answers: [
      {text: '1984', correct: false},
      {text: '1995', correct: true},
      {text: '2001', correct: false},
    ],
  },
  // ···
];

Immer  

This time, we use the library Immer to help us with non-destructively updating data. It works as follows:

import produce from 'immer';
const updatedData = produce(originalData, (draftData) => {
  // Modify draftData destructively here...
});

We provide the Immer function produce() with the data to be updated, originalData and a callback. The callback destructively changes its parameter draftData so that it has the desired shape. It treats draftData as if it were originalData, but the former is actually a special object: Immer observes the operations that are performed on it. They tell Immer how to create a modified copy of originalData.

The following function uses Immer to add two user interface properties to entries:

  • Property .open is added to each entry (line A).
  • Property .checked is added to each answer (line B).
function addUiProperties(entries) {
  return produce(entries, (draftEntries) => {
    for (const entry of draftEntries) {
      entry.open = true; // (A)
      for (const answer of entry.answers) {
        answer.checked = false; // (B)
      }
    }
  });
}

If we handled the non-destructive updating ourselves, addUiProperties() would look as follows:

function addUiProperties(entries) {
  return entries.map((entry) => ({
    ...entry, // (A)
    open: true,
    answers: entry.answers.map((answer) => ({
      ...answer, // (B)
      checked: false,
    }))
  }));
}

In line A, we copy entry via spreading (...) while adding the new property .open and overriding the existing property .answers (whose value we need to copy).

We can see that the Immer-based code is simpler, but not much. As we’ll see soon, Immer especially shines with deeply nested data.

The root controller pattern  

This is how the root component Quiz is rendered into the HTML page:

ReactDOM.render(
  html`<${Quiz} entries=${addUiProperties(entries)} />`,
  document.getElementById('root'));

The root component Quiz knows the complete model (the result of addUiProperties(entries)). Each of its subcomponents receives part of the root model and a reference to a so-called root controller, which is an instance of the following class:

class RootController {
  constructor(entries, setEntries) {
    this.entries = entries;
    this.setEntries = setEntries;
  }
  setAnswerChecked(entryIndex, answerIndex, checked) {
    const newEntries = produce(this.entries, (draftEntries) => { // (A)
      draftEntries[entryIndex].answers[answerIndex].checked = checked;
    });
    this.setEntries(newEntries); // refresh user interface
  }
  closeEntry(entryIndex) {
    const newEntries = produce(this.entries, (draftEntries) => { // (B)
      draftEntries[entryIndex].open = false;
    });
    this.setEntries(newEntries); // refresh user interface
  }
}

Whenever a user interaction happens in one of the subcomponents of Quiz, that subcomponent asks the root controller to change the root model accordingly. After the change, the root controller calls this.setEntries (which originally was created via the useState() hook) and the whole user interface is recreated.

The root controller having access to the whole model has one considerable benefit: It’s easy to manage cross-component changes.

In line A and line B, we used Immer to non-destructively update this.entries. This time, the code is much simpler than without Immer.

I call the pattern of passing a root controller object to all user interface components the root controller pattern.

The user interface components  

The root component Quiz  

This is the implementation of the root component:

function Quiz({entries: initialEntries}) {
  const [entries, setEntries] = useState(initialEntries);
  const root = new RootController(entries, setEntries);
  return html`
    <${React.Fragment}> <!--(A)-->
      <h1>Quiz</h1>
      <${AllEntries} root=${root} entries=${entries} />
      <hr />
      <${Summary} entries=${entries} />
    <//>
  `;
}

A React component must return valid virtual DOM data. Valid data is:

  • A single virtual DOM element
  • A boolean, a number, or a string
  • null (which produces zero output)
  • An Array where each element is valid virtual DOM data

In line A, we use the special component React.Fragment to return multiple elements. This works better than an Array because conceptually, Array elements tend to be similar in nature and produced via iteration. And with an Array, we would have to specify key attributes.

Quiz has two subcomponents: AllEntries and Summary.

The component AllEntries  

Each quiz entry is initially open – the user can check and uncheck answers as desired. Once they submit the answers they think are correct, the entry is closed. Now they can’t change the selected answers anymore and the quiz app lets them know if they got their answers right or not.

function AllEntries({root, entries}) {
  return entries.map((entry, index) => {
    const entryKind = entry.open ? OpenEntry : ClosedEntry;
    return html`
      <${entryKind} key=${index} root=${root} entryIndex=${index} entry=${entry} />`
  });
}

The component OpenEntry  

The component OpenEntry displays entries that are open:

function OpenEntry({root, entryIndex, entry}) {
  return html`
    <div>
      <h2>${entry.question}</h2>
      ${
        entry.answers.map((answer, index) => html`
          <${OpenAnswer} key=${index} root=${root}
            entryIndex=${entryIndex} answerIndex=${index} answer=${answer} />
        `)
      }
      <p><button onClick=${handleClick}>Submit answers</button></p> <!--(A)-->
    </div>`;

  function handleClick(event) {
    event.preventDefault();
    root.closeEntry(entryIndex);
  }
}

function OpenAnswer({root, entryIndex, answerIndex, answer}) {
  return html`
    <div>
      <label>
        <input type="checkbox" checked=${answer.checked} onChange=${handleChange} />
        ${' ' + answer.text}
      </label>
    </div>
  `;

  function handleChange(_event) { // (B)
    // Toggle the checkbox
    root.setAnswerChecked(entryIndex, answerIndex, !answer.checked);
  }
}

With an open entry, we can submit our answers via a button (line A). Note how the click handler handleClick() uses the root controller instance in root to change the model and to refresh the user interface.

We also refresh the complete user interface whenever the user changes a checkbox (line B).

The component ClosedEntry  

The component ClosedEntry displays entries that are closed:

function ClosedEntry({root, entryIndex, entry}) {
  return html`
    <div>
    <h2>${entry.question}</h2>
    ${
      entry.answers.map((answer, index) => html`
        <${ClosedAnswer} key=${index} root=${root} entryIndex=${entryIndex} answer=${answer} answerIndex=${index} />
      `)
    }
    ${
      areAnswersCorrect(entry) // (A)
      ? html`<p><b>Correct!</b></p>`
      : html`<p><b>Wrong!</b></p>`
    }
    </div>`;
}

function ClosedAnswer({root, entryIndex, answerIndex, answer}) {
  const style = answer.correct ? {backgroundColor: 'lightgreen'} : {};
  return html`
    <div>
      <label style=${style}>
        <input type="checkbox" checked=${answer.checked} disabled /> <!--(B)-->
        ${' ' + answer.text}
      </label>
    </div>
  `;
}

This time, all answers are disabled – we can’t check or uncheck them anymore (line B).

We give the user feedback if they got their answers right (line A).

The component Summary  

Component Summary() is shown at the end of the quiz:

function Summary({entries}) {
  const numberOfClosedEntries = entries.reduce(
    (acc, entry) => acc + (entry.open ? 0 : 1), 0);
  const numberOfCorrectEntries = entries.reduce(
    (acc, entry) => acc + (!entry.open && areAnswersCorrect(entry) ? 1 : 0), 0);
  return html`
    Correct: ${numberOfCorrectEntries} of ${numberOfClosedEntries}
    ${numberOfClosedEntries === 1 ? ' entry' : ' entries'} <!--(A)-->
  `;
}

In line A, we once again have to account for the JSX whitespace rules: In order for the number after “of” to be separated from the word “entry” or “entries”, we have to insert a space before the latter.

This component summarizes:

  • numberOfClosedEntries: How many entries have we answered already?
  • numberOfCorrectEntries: How many entries did we answer correctly?

Exercises  

  • Use the browser function fetch() to load a JSON file with the quiz data.
    • You can put that JSON file in the html/ directory.
    • You will invoke fetch() first and render Quiz after you have received the JSON data.
  • Change RootController so that it doesn’t use the Immer library. That should make it obvious how useful that library is.

How does Snowpack work?  

Snowpack is configured via the file snowpack.config.json. Its contents are (with one minor setting omitted):

{
  "mount": {
    "html": "/",
    "js": "/js"
  }
}

Apart from the dependencies that we have stated in package.json, that is all the configuration data that Snowpack needs: mount states which directories contain data that Snowpack should serve or build.

Snowpack serves and builds three kinds of data:

  • The mounted directories
  • Directory __snowpack__/ with Snowpack-related metadata (which is can be used in advanced building scenarios)
  • Directory web_modules/:
    • After an npm install, directory node_modules/ contains all dependencies mentioned in package.json. There are usually multiple files per package and some of them may be in the CommonJS module format which browsers don’t support natively.
    • Snowpack examines the JavaScript code in the mounted directories. Whenever one of the dependencies is mentioned, it compiles the package in node_modules/ into browser-compatible code in web_modules/. Often the latter is a single file.

Additionally Snowpack slightly changes the imports in mounted JavaScript files.

// Imports in a JavaScript file:
import ReactDOM from 'react-dom';
import {useState} from 'react';

// Output generated by Snowpack:
import ReactDOM from '/web_modules/react-dom.js';
import {useState} from '/web_modules/react.js';

Other than the imports, Snowpack doesn’t change anything in JavaScript files (during development time, when using the server).

Building  

Building is performed via the following command:

npm run build

Snowpack writes the complete web app into the directory minimal-react/build (including web_modules/ etc.). This version of the app works without the Snowpack development server and can be deployed online.

Conclusion  

State management via the root controller pattern  

I’ve used the root controller pattern in several of my React applications and I got surprisingly far without needing a more advanced state management solution.

If you don’t want to pass the root controller explicitly, you can take a look at the useContext hook.

Next steps  

The React ecosystem consists of a small core (the library itself) that is complemented by many external add-ons. That has an upside and a downside:

  • Upside: It’s relatively easy to learn the core. The add-ons are all optional.
  • Downside: There is a lot of choice w.r.t. the add-ons, which can result in serious analysis paralysis.

There are two good options for getting started:

  • Start small, e.g. with the setup shown in this blog post. Only add more libraries if you really need them – each one increases the weight of the project.

  • The React team has created a “batteries included” application setup called create-react-app. That’s a good starting point if you want more functionality from the get-go.

Learning more about the React ecosystem  

  • If you want to read more about the React ecosystems, there are many guides available online. For example, “React Libraries in 2020” by Robin Wieruch.

  • You can also use React to develop native applications: React Native is also based on a virtual DOM. But in this case, it is used to render native user interfaces. This is a compelling proposition, especially for cross-platform applications.