You might not need React

Last edited April 2021

For me, React’s biggest impact is the ability to create components that are independent and composable. However, this approach is feasible in modern JavaScript as well.

Components can be defined as functions returning strings. ES2015’s template literals (using backticks) are particularly useful for this:

const Swatch = (color) =>
  `
    <div style="background: ${color}; padding: 10px">
      ${color}
    </div>
  `;

const App = () =>
  `
    ${Swatch('red')}
    ${Swatch('blue')}
    ${Swatch('gray')}
  `;

document.body.innerHTML = App();

The example above isn’t particularly useful — it’d be quicker in plain old HTML. However, defining these components in JavaScript, rather than resorting to HTML, is useful if you want to instantiate many components, each with their own parameters. Consider the following, which creates a bunch of color swatches all at once:

const Swatch = (hue, lightness) => {
  let color = `hsl(${hue}, 100%, ${lightness}%)`;

  return (
    `
      <span style="
        display: inline-block;
        background: ${color};
        padding: 5px
      ">
        ${color}
      </span>
    `
  )
};

const Swatches = (hue) =>
  `
    <div style="padding: 20px">
      ${Swatch(hue, 10)}
      ${Swatch(hue, 20)}
      ${Swatch(hue, 30)}
      ${Swatch(hue, 40)}
      ${Swatch(hue, 50)}
      ${Swatch(hue, 60)}
      ${Swatch(hue, 70)}
      ${Swatch(hue, 80)}
      ${Swatch(hue, 90)}
    </div>
  `;

const App = () =>
  `
    ${Swatches(0)}
    ${Swatches(90)}
    ${Swatches(180)}
  `;

document.body.innerHTML = App();

As with React (or rather, ReactDOM), it might be best to render into a pre-defined element, such as one with the ID app:

<div id="app"></div>
const render = () => {
  let app = document.querySelector("#app");
  app.innerHTML = App();
};

render();

Handling state changes

Consider the following code, which renders three tabs (Very ugly ones — they barely look like tabs! ) but has no interactivity whatsoever:

let data = {
  curTab: "Home",
  tabs: [
    {
      name: 'Home',
      content: 'Here is the content for the Home tab'
    },
    {
      name: 'About',
      content: 'Here is the content for the About tab'
    },
    {
      name: 'Skills',
      content: 'Here my list of skills'
    }
  ]
};

const app = document.querySelector("#app");

const render = () => {
  app.innerHTML = App();
};

const Tab = (tab) =>
  `
    <li style="display: inline; margin: 0">
      <a href="#">
        ${tab.name}
      </a>
    </li>
  `;

const TabList = () =>
  `
    <ul style="margin: 0; padding: 0">
      ${data.tabs.map(t => Tab(t)).join('')}
    </ul>
  `;

const App = () =>
  `
    ${TabList()}
    ${data.tabs.find(t => t.name === data.curTab).content}
  `;

render();

Note the data variable near the top, which stores all the application state.

To make the app switch to a different tab, the tab links need to be updated to describe the action that needs to be taken:

const Tab = (tab) =>
  `
    <li style="display: inline; margin: 0">
      <a
        style="${tab.name == data.curTab ? "font-weight: bold" : ""}"
        data-action="select-tab"
        data-tab-name="${tab.name}"
        href="#"
      >
        ${tab.name}
      </a>
    </li>
  `;

There are three bits of interest in this new Tab() component:

  1. The a element is bolded when it corresponds to the current tab. It compares the name of the tab to be rendered with the name of the active tab.

  2. The data-action attribute describes what needs to happen when this a element is acted upon. In this case, it will trigger the (as-yet-undefined) select-tab action.

  3. The data-tab-name attribute contains extra information that will be used by the select-tab action.

There are some improvements that you could take into account. Rather using the global data.curTab, it might be better to pass it to the Tab(). Also, rather than data-action, it might make more sense to have data-onclick.

To handle the click, we need to attach an event handler to the application DOM element:

const init = () => {
  app.addEventListener('click', handleClick);
  render();
};

The handleClick() function needs to be defined:

const handleClick = (e) => {
  let actionName = e.target.dataset.action;

  if (actionName === "select-tab") {
    data.curTab = e.target.dataset.tabName;
    render();
  }
};

This is where the select-tab action is handled. You can see that it uses dataset.tabName, which corresponds with the data-tab-name attribute (the translation between kebab-case and camelCase happens automatically).

After every change, we need to call render() so that the app is re-rendered. This isn’t particularly performant, but if you need good performance, then you’d probably be using a proper framework like React anyway.

Lastly, rather than calling render() at the end of the script, we need to call init(), so that it both sets up the event handling, and renders the app for the first time:

// used to be render()
init();

Limitations

This approach works fairly well for simple apps that need a bit of interactivity, but for which React (and the toolchain that comes with it, with Babel and WebPack and whatnot) is overkill.

There are likely other limitations, though for building simple web apps, this approach is both fast to get started with, and has no dependencies — it’s plain-old JavaScript.