July 05, 2024

#React Patterns in Umai

Last year, I forked hyperapp to create the single-page application library I've always wanted -- umai. umai is heavily influenced by Mithril.js, which has unfortunately been in stasis for a few years now. My goal in creating umai can be summed up in a few bullet points:

  • Create a hyper minimal, but ergonomic UI library in a tiny package size (~1.7kb)
  • Fully embrace Mithril.js's closure components, while cutting the cruft and unnecessary baggage (class components, object components, lifecycle events, view property).
  • Full tree redraws on event handler calls with support for async handlers

Since then, I've used umai on multiple small personal projects and have been happy with the speed and minimalism I've been able to rebuild apps I had previously written in React, Mithril, and Svelte.

In this post, I'd like to translate React patterns taken from react.dev into umai equivalents to demonstrate how easy it is for someone with previous SPA experience to get started with umai. I'll be providing links to live examples if you'd like to alter the examples.

#Component Composition

Let's start with a very basic example of component composition. Being able to reuse components is the bread and butter of any UI library.

#React

function MyButton() {
  return (
    <button>
      I'm a button
    </button>
  );
}

function MyApp() {
  return (
    <div>
      <h1>Welcome to my app</h1>
      <MyButton />
    </div>
  );
}

#umai

function MyButton() {
  return (
    <button>
      I'm a button
    </button>
  );
}

function MyApp() {
  return (
    <div>
      <h1>Welcome to my app</h1>
      <MyButton />
    </div>
  );
}

Live Example

Notice anything interesting? They're both the same! If you're used to stateless components in React, moving to umai will be a breeze. This includes basics like props and conditional rendering. The following example is compatible with React and umai:

function MyButton() {
  return (
    <button>
      I'm a button
    </button>
  );
}

function MyApp({ showButton = true } = {}) {
  return (
    <div>
      <h1>Welcome to my app</h1>
      {showButton ? <MyButton /> : 'Button is hidden!'}
    </div>
  );
}

Live Example

Let's get started with examples that contain actual differences.

#Updating the screen

#React

Basic state management takes center stage for this example. In React, we'll use the familiar useState hook to create a reusable component that retains its own state.

import { useState } from 'react';

function MyApp() {
  return (
    <div>
      <h1>Counters that update separately</h1>
      <MyButton />
      <MyButton />
    </div>
  );
}

function MyButton() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      Clicked {count} times
    </button>
  );
}

#umai

umai in comparison does not feature hooks. Instead, umai uses the concept of "closure components". State is defined using simple, mutable variables declared with let.

function MyApp() {
  return (
    <div>
      <h1>Counters that update separately</h1>
      <MyButton />
      <MyButton />
    </div>
  );
}

function MyButton() {
  let count = 0;

  function handleClick() {
    count += 1;
  }

  return () => (
    <button onclick={handleClick}>
      Clicked {count} times
    </button>
  );
}

Live Example

A few important distinctions to be made with the umai version:

  • umai uses browser standard names for event handlers (onclick vs onClick)
  • Notice that the handleClick function in React will be re-created on every re-render. This is because React components are also render functions. Hooks are needed to get around this limitation and to store stateful data in a hidden global store invisible to the developer. When declaring stateful components with umai, instead of returning JSX, we return a render function. This forms a closure that retains the state of the outer function. handleClick in the umai version is only created once.
    • This means no need for useCallback! Memoizing components is much simpler in umai.

#Focusing a text input

#React

Sometimes, you need access to the DOM, whether it be to integrate with a third-party library or trigger DOM-specific effects. In React, a ref can be used to attach a DOM node to a variable accessible to React, often using the useRef hook.

import { useRef } from 'react';

function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

#umai

In umai, every element has a special dom property which accepts a handler that receives the DOM node upon creation. Using a let variable, we can grab a reference to this node and use it at a later time.

function Form() {
  let inputEl = null;

  function handleClick() {
    inputEl.focus();
  }

  return () => (
    <div>
      <input dom={node => inputEl = node} />
      <button onclick={handleClick}>
        Focus the input
      </button>
    </div>
  );
}

Live Example

#Updating state based on previous state from an effect

#React

React heavily relies on the useEffect hook for usecases involving:

  • Running code on mounting of a component
  • Running code on dependency updates (whether it be state or props)
  • Running code on unmounting of a component
  • Running code related to external systems (the DOM, third-party libraries, etc.)

This hook famously replaced every lifecycle event used when class components were idiomatic React.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(intervalId);
  }, []);

  return <h1>{count}</h1>;
}

#umai

In comparison, umai is much more minimal. There is no special API for effects. However, we can re-use an existing concept we touched on earlier.

The handler passed to the dom property is only called once upon DOM node creation. Therefore, we can leverage it to run effects on "component mount". Additionally, a cleanup function can be returned in this handler to run code upon element removal.

function Counter() {
  let count = 0;
  let intervalId = null;

  function onMount() {
    intervalId = setInterval(() => {
      count += 1;
      redraw();
    }, 1000);

    return () => clearInterval(intervalId);
  }

  return () => <h1 dom={onMount}>{count}</h1>;
}

Live Example

Some interesting things to note:

  • Since stateful components in umai are "closure components", we don't have to keep track of dependencies with dependency arrays like in React -- the outer function is only run once on component mount.
  • umai uses "global redraws". Rerenders are only triggered by event handlers defined in your JSX, or by manual redraw() calls. During asynchronous operations, calling redraw() is necessary to tell umai to re-render.

#Adding a reducer to a component

#React

React's useReducer hook allows developers to implement an immutable store similar to Redux. A nice thing about this pattern is multi-property state alterations can be designated as "actions" which may optionally contain payloads.

import { useReducer } from 'react';

function reducer(state, action) {
  if (action.type === 'incremented_age') {
    return {
      age: state.age + 1
    };
  }
  throw Error('Unknown action.');
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { age: 42 });

  return (
    <>
      <button onClick={() => {
        dispatch({ type: 'incremented_age' })
      }}>
        Increment age
      </button>
      <p>Hello! You are {state.age}.</p>
    </>
  );
}

#umai

You may have already guessed, but umai does not feature any reducer methods or utilities to build a store. However, you may leverage any store/observable library to implement your own, such as dipole, preact-signals, MobX, Immer, or even Redux. Below, I give an example using vyce, a 463 byte observable store. Notice that the reducer is unchanged from the React example.

import { store } from 'vyce';

function reducer(state, action) {
  if (action.type === 'incremented_age') {
    return {
      age: state.age + 1
    };
  }
  throw Error('Unknown action.');
}

function Counter() {
  const state = store({ age: 42 });
  const dispatch = (action) => state((prev) => reducer(prev, action));

  return () => (
    <div>
      <button onclick={() => {
        dispatch({ type: 'incremented_age' })
      }}>
        Increment age
      </button>
      <p>Hello! You are {state().age}.</p>
    </div>
  );
}

Live Example

umai aims to be closer to vanilla JavaScript than React. In the same spirit, we can choose to embrace mutability using a pattern that avoids bugs that would normally arise from it -- and we can do so without any third-party libraries.

const createState = () => ({ age: 42 });

const createActions = (state) => ({
  incrementAge() { state.age += 1; }  
});

const state = createState();
const actions = createActions(state);

function Counter() {
  return (
    <div>
      <button onclick={actions.incrementAge}>
        Increment age
      </button>
      <p>Hello! You are {state.age}.</p>
    </div>
  );
}

Live Example

  • Instead of using strings to name actions, we simply use functions, e.g., incrementAge
  • The createActions factory function produces a unique actions bag scoped to the state object passed to it. These actions are the only means of updating the state.

One caveat of this approach is that it is still possible to mutate the state object directly. If you are using TypeScript, you can use the Readonly utility type to discourage this.

interface State {
  age: number;
}

const createState = (): State => ({ age: 42 });

const createActions = (state: State) => ({
  incrementAge() { state.age += 1; }
});

const state: Readonly<State> = createState();
const actions = createActions(state);

state.age = 10; // Cannot assign to 'age' because it is a read-only property.

Another interesting thing to point out is that React batches state updates. This is to prevent unncessary re-renders upon updating multiple state variables. For example, if you call setState(x => x + 1) 3 times in a row, React will only re-render once. This is a smart and sophisticated optimization.

This kind of optimization is unneeded in umai. Whereas the developer must remember the rules of re-rendering in React as it pertains to several hooks, umai only re-renders in these two instances:

  1. An event handler is called, e.g., onclick, onchange, oninput
  2. redraw() is called

#Wait, so should I migrate to umai?

No. Unsurprisingly, umai is very much a work-in-progress, a one-man hobby project, and not as feature-filled as React or Mithril.js for that matter. At the time of writing, umai is at version 0.2.6. Here is a short list of things React has that umai does not:

  • SSR support -- there is currently no way to render umai components with Node/Deno/Bun
  • Keyed fragment support
  • Error handling and useful error messaging
  • Developer Tools
  • Frameworks (Next.js, Remix, Gatsby, Waku)
  • Mobile frameworks like React Native

Nevertheless, I think there is value in comparing it to React, if only to see what else is possible in the realm of uncompiled, single-page application libraries. I firmly believe the patterns presented by umai, and its main inspiration (Mithril.js) are viable alternatives. And, in my opinion, these patterns are better than the status quo set by React and Facebook/Meta. For that reason, I do encourage anyone with a small, low-stakes hobby project in mind to take umai for a spin.

For more examples of umai in action, check out the examples section of the README.