This blog post explains how to get started with React while using as few libraries as possible.
Things you should know before reading 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:
snowpack
@snowpack/plugin-react-refresh
react
react-dom
htm
immer
The repository minimal-react
contains the examples that we are exploring in this blog post:
The repository has the following structure:
minimal-react/
html/
: HTML filesjs/
: JavaScript codeREADME.md
: Instructions for installing and running the projectpackage.json
: Configuring the npm package managersnowpack.config.json
: Configuring the Snowpack build toolpackage.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:
npm run start
npm start
npm run build
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.
This object-oriented approach gets its name from three roles that objects play in it:
Traditional MVC-based user interfaces work as follows:
This approach has downsides:
React works differently:
Benefits of this approach:
The first example is in the file minimal-react/html/counting-clicks.html
.
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)
Consider the following syntax from the previous example:
html`<${CountingClicks} rootModel=${rootModel} />`
There are two layers to this syntax.
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:
['I have ', ' ', '!']
are static (the same each time this particular function call is made)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.
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:
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.
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 })
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.
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,
};
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:
handleIncrement
rootModel.numberOfClicks
handleReset
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.
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()
.
The second example is in the file minimal-react/html/expandable-sections.html
.
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.
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.
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));
}
}
In line A, we are specifying CSS via an object literal:
margin-bottom
are translated to JavaScript identifiers such as marginBottom
.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.
In line D, we are evaluating a condition:
null
).In line E, we are dealing with clicks on the triangle:
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
.
expandExactlyOneSection()
.The third example is in the file minimal-react/html/quiz.html
.
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},
],
},
// ···
];
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
:
.open
is added to each entry (line A)..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.
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.
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:
null
(which produces zero output)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
.
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} />`
});
}
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).
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).
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?fetch()
to load a JSON file with the quiz data.
html/
directory.fetch()
first and render Quiz
after you have received the JSON data.RootController
so that it doesn’t use the Immer library. That should make it obvious how useful that library is.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:
__snowpack__/
with Snowpack-related metadata (which is can be used in advanced building scenarios)web_modules/
:
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.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 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.
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.
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:
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.
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.