valand.dev

Machine Tree: React aside from the UI

Among a nauseatingly huge number of "modern web UI frameworks" out there, React stands tall. It is a very nimble library to write a JavaScript-based app on top of. React app code can stay way more clear, consistent, and recognizable regardless of complexity, compared to other frameworks. Why? Because unlike other frameworks, which are more of a template engine, React focuses on assisting to build an asynchronous machine tree.

Update:

  • Looking forward for comments, feedback, etc. Do submit here HN.

overview

React Webpage as of node tree

Illustrated above, on the right, a simple webpage. It contains a huge main section and three links on the left for the users to navigate between three pages. On the left is the visual of the React nodes that make up the simple web page. The nodes form a tree. Each node has one or more relationships to other nodes, which tells us a story:

This App has a Router that regulates the application routing system which affects everything below it. The page has Sidebar that has three Links, and a Main section that renders a Page.

React works by enabling developers to compose React components in the shape of a tree. This tree-shaped composition is a hierarchy of mini machines. Each component, each machine, has its own domain/problem/concern/task.

event-propagation

Event and effects chained into a flow

How does this work under the hood?

  • Router manages a state that describes what route it is now on and how it changes, and it affects every descendant of it. It provides its state and methods to its descendants.
  • Link renders an element and listens to it for "click" events. If there's a "click", it notifies the nearest ancestor Router component via the method it passed.
  • Main listens/subscribes to the nearest ancestor Router. Any change in the Router's state causes Main to re-renders too.

Note: Render is the term in React world equivalent to refreshing a component. It doesn't directly concern any GPU-related computation.

Those seem a lot for simple interaction, how is it easier in React?

1) React automates spawning/despawning children. With this, you don't need to write "if [some condition] AND [Child is not spawned] then spawn Child". React simplifies this:

// This is the `Main` component
// It receives props with type { path: string }
// In this snippet, it is expected that the props is provided by Router,
// the parent of Main, see above illustration
const Main = (props: { path: string }) => (
  <>
    {props.path === "/home" && <HomePage />}
    {props.path === "/about" && <AboutPage />}
    {props.path === "/contact-us" && <ContactUsPage />}
  </>
);

The above snippet declares that a certain condition causes a certain page to be shown, if path is /home, the component renders HomePage, and so on. The expression between the sign {} is a JavaScript expression.

Main seems like a simple function, but because it is returning a ReactNode it can be a component. Once a component function is included in a render via ReactDOM.render, React creates a subroutine that manages components in a tree. The function above receives path as props. Props are values passed by the parent component into this component. When a prop changes, it will trigger a re-render. A re-render is when React calls this function again. How the props change though is not our concern for now.

If a render's result differs from the previous render's, maybe because of changing props, React will reconcile the two return values. If both renders yield a child component, say, HomePage and AboutPage, the previous component, HomePage will be removed from the tree, and the new component AboutPage will replace it.

This is the famous declarative syntax of React people talking about.

Notes:

  • <> and </> is a shorthand for React Fragment
  • Other than props change, state change and other dependencies (e.g. context, ref) change also cause rerender.

2) React provides free dependency injection via context. What is dependency injection? It is basically a technique to inject object/data/function as a dependency into another object. In React case the injection happens to a component. A component can specify that it needs a certain dependency and we can build an environment that can provide it. To be more specific, a Link component uses Router as a dependency to work. It needs access to a Router to tell that it is clicked and a change to a route is to be made. Link can be written like this:

const Link = (props: { to: string; children: React.ReactNode }) => {
  const routerAPI = useContext(RouterAPIContext);

  // !!router is to convert router to boolean in case
  // it is not provided in this context
  return (
    !!routerAPI && (
      <a
        href="#"
        onClick={(e) => {
          e.preventDefault(); // The most popular event method in StackOverflow
          routerAPI.push(props.to); // Calling Router's method
        }}
      >
        {children}
      </a>
    )
  );
};

In the above snippet, Link uses routerAPI. Link also receives the props to and children from its parent component. As explained in point 1, if either to, children or routerAPI changes, the Link will be re-rendered. Link will be used/rendered like this:

const Sidebar = () => (
    <Link to="/home">Home</Link>
    <Link to="/about">About</Link>
    <Link to="/contact-us">Contact Us</Link>
);
// /home, /about, and /contact-us will be assigned to Link's props.to
// Home, About, and Contact Us will be assigned to Link's props.children

Also, let's change Main to use context so it doesn't need to be the child of Router. We need to introduce Route which uses Router too.

const Route = (props: { path: string; render: () => React.ReactNode }) => {
  const routerData = useContext(RouterDataContext);
  // only render when routerData.path matches props.path
  return !!routerData && routerData.path === props.path ? props.render() : null;
};

const Main = () => (
  <>
    <Route path="/home" render={() => <HomePage />} />
    <Route path="/about" render={() => <AboutPage />} />
    <Route path="/contact-us" render={() => <ContactUsPage />} />
  </>
);

3) React allows internal states and asynchronous actions in the components. This is the main reason I see React components as machines. React provides the functions useState and useEffect (or alternatively state attribute and lifecycle methods if class component style is used) that allow a component to have its own lifecycle. We will use them to implement Router which is a simple routing "system" for the application.

Let's start with Router's core functionality first. I will use React's new addition, hooks, to demonstrate the ease to compose logics in React. Writing hooks is just as simple as writing components, except, instead of returning React.ReactNode, they return whatever you need.

// Define the signature of RouterCoreAPI
// For other component to use
type RouterCoreAPI = {
    events: {
        // An imaginary event emitter, just accept as it is for now
    	pathChange: TypedEvent<null>
    },
    path: string;
    push: (path: string) => unknown;
};
const useRouterCoreAPI = () => {
    const [core, setCore] = useState<null | RouterCoreAPI>(null);
    useEffect(() => {		// This block will be executed after the first render
        const pathChange = new TypedEvent<null>();
        const newCore: RouterCoreAPI = {
      		events: { pathChange, },
            path: "",
            push: (newPath: string) => {
                newCore.path = newPath;
                pathChange.emit();
            },
        };
        setState(newCore);	// Initialize the state
    }, []);					// This awkward empty array is to make useEffect only run once
	return core;
};

// Define RouterData
type RouterData = {
    path: string;
};
const useRouterData = (core: null | RouterCore) => {
    // Why null | RouterCore?
    // Hooks cannot be used in an if block
    // One of hooks' trait that is not beginner friendly
    // The rule doesn't adhere to plain JavaScript
    const [state, setState] = useState<null | RouterData>;
	useEffect(() => {
        if (!core) return;		// Only continue if core is not null
        const unsub = core.events.pathChange.subscribe(() => {
            setState({ path: core.path });
        });
        setState({ path: core.path });

        // Function returned from useEffect is the `cleanup` function
        // The current iteration's `cleanup` function is called before
        // the next iteration of useEffect begins
        return () => {
            unsub();
        };
    }, [core]);
    // Again this awkward array is to tell when useEffect should be called
    // In this instance, useEffect executes when `core` changes
    // behind the scene, React matches `previous render's core` === `next render's core`
    // if it's false, the block inside `useEffect` is called
    return state;
};

Now that the logic is established, let's use the logic in the Router component. Router is a component wrapper and a context provider so that it renders its props.children into it and API and data can be used by Link and Route.

// These contextes are eventually used via `useContext(theContext)` in Link and Route
const RouterAPIContext = React.createContext<RouterCoreAPI>(null);
const RouterDataContext = React.createContext<RouterData>(null);
const Router = ({ children }: { children: React.ReactNode }) => {
  // Here's how Router composes the logics written in Hooks
  const coreAPI = useRouterCoreAPI();
  const data = useRouterData(coreAPI);
  return (
    <RouterAPIContext value={coreAPI}>
      <RouterDataContext value={data}>{children}</RouterDataContext>
    </RouterAPIContext>
  );
};

Last let's use Router and other components to compose the whole app.

const App = () => (
  <Router>
    <Main />
    <Sidebar />
  </Router>
);

Look at that beautiful directive. It literally describes the architecture of the whole application.

Note: Using hooks in this particular case is unnecessary as it was for a demonstration of React's composability. Extracting logic to a hook is done usually because: 1.) The extracted part of the logic is reusable in other components, or 2.) the logic is simply too long and can be extracted without inciting semantic dissonance.

4) React doesn't stray far from its host, JavaScript, and JavaScript VM. This point is an important and powerful concept in a library like React. JSX is a superset of JavaScript rather than another whole language. In consequence, writing React, with or without JSX/TSX, feels like writing JavaScript/TypeScript. If you pass a function as a prop in JSX, you write a JavaScript function.

In contrast, other web UI library such as Vue, Angular, or Svelte uses a separate templating language for its templating purpose. In Vue, for example, oftentimes you are required to write a computed function to bridge a value in the Vue instance to the template. Meanwhile JSX's <ComponentName attribute={value}>{childrenNode}</ComponentName> roughly translates to React.createElement(ComponentName, { attribute: value }, [childrenNode]) with key attributes added on compile time.

Being a superset of JavaScript, JSX makes React many-folds more expressive and consistent to JavaScript than its competitors. Consistency keeps syntactical ambiguousness low, while expressiveness helps with productivity, enabling sentences to be written concisely, packing more dense meaning in shorter expressions.

Being a superset of JavaScript also allows compiler and IDE programmer to reuse existing JavaScript/TypeScript parser rather than having to write a custom parser for the custom templating languages, allowing faster iterations and increments to JSX+React compilers and IDE support.

Note: Although there are exceptions. React's hooks introduce a constraint that is hard to enforce. Hooks cannot be called inside an if block. This applies both to primitive hooks and custom hooks. A linter cannot fully guard an inexperienced developer against this stumbling block. Without prior proper knowledge, this constraints can be an unforeseen obstacle to a project.

Those points above are React minus the UI. With only those, writing a clearer, more consistent, and more recognizable code at high complexity compared to code built on other frameworks.

Architecting React App

Leveraging JSX and React, we are served with a language and a runtime to compose components intuitively.

<A>
  <B />
</A>

The above expression tells us:

  • In terms of relationship, A is an ancestor of B.
  • In terms of lifetimes, A lives before B and dies after B.
  • In terms of dependency, B may depend on A, but not vice versa.

Understanding these properties helps in making architecting React App a whole lot easier. The more a component is depended on, the higher is its place in the tree (the root being at the top). One of my go-to principle for architecting any kind of software at any level of the stack is to pay attention to the role of the components, the relationship between, their lifetimes, and the dependency tree. JSX+React makes three out of four easy.

Case: Global toast notification

With composable logic and dependency injection, a single component that seems "global" can be split and composed at a different level for presentational purposes. For example, the common case, an application-wide toast notification.

event-propagation

Application with Toast Notification

In this application, the notification system and the toast notification area are separated because they are working on two different tasks. Toast notification system provides application-wide access to the API of the notification system. Other components below the "system" can access its API and making calls to it, to create a toast notification for example. The Toast notification area is a presentational component that presents the notification visually. It accesses the API from the Toast notification system to read its state, the notification queue, and display it on the screen. The separation between the "system" and the "presentational component" allows the Toast notification area to be placed at a lower level than the "system", making it available to be composed with the rest of the app.

Case: Swarm health checker

A rarer usage of React component is to utilize children as workers. For example, you have a system that tracks a swarm of guest services. Working under the same protocol, multiple guest services, at the start of their lifecycle, contacted the HealthCheckerRegulator. For every service that contacts it, it spawns a HealthChecker that tracks the service if they are alive. This is a bit like Kubernetes's health checker but implemented using React.

event-propagation

Application with Toast Notification. Oh btw, that rectangle is a process boundary.

CoreHealthChecker is a passthrough component (renders props.children), but unlike ToastNotificationSystem. it also spawns HealthCheckers, one for each guest service, which is identified by its hostName. It also supplies a callback for when a service is "unhealthy". Whenever a new guest service is registered, a new HealthChecker is automatically spawned on re-render. And whenever a new guest service is removed from the set, the corresponding HealthChecker is unspawned.

const CoreHealthChecker = ({ children }: { children: React.ReactNode }) => {
  const register = useCoreHealthCheckerLogic();
  return !!register ? (
    <>
      {children}
      {Array.from(register.hostNames).map((hostname) => (
        <HealthChecker
          key={hostName}
          hostName={hostName}
          onUnhealthy={() => register.removeGuest(hostName)}
        />
      ))}
    </>
  ) : null;
};

For the sake of completeness here's the logic implementation. HTTPServer is an imaginary class that provides, well HTTPServer implementation. That's all the need-to-knows.

const useCoreHealthCheckerLogic = () => {
  const [register, setRegister] = useState(null);
  useEffect(() => {
    const hostNames = new Set<string>();
    const httpServer = new HTTPServer();
    httpServer
      .on("GET /register", (req) => {
        hostnameSet.push(req.host);
        refresh();
      })
      .listen("80");

    const removeGuest = (hostName: string) => hostNames.delete(hostName);

    const register = {
      hostNames,
      removeGuest,
    };

    // refresh create a copy of register and put it on setState
    // so that it is detected as changed by its dependee
    // Remember: React uses === to check for dependency changes
    const refresh = () => setRegister({ ...register });

    refresh();

    return () => {
      httpServer.destroy();
    };
  }, []);

  return register;
};

Each HealthChecker spawn its own HTTPClient that calls the "healthz" endpoint at interval. When HealthChecker is spawned it independently checks for the corresponding guest service's health at its own pace, independent from the pace and lifecycle of other HealthCheckers or the CoreHealthChecker.

const HealthChecker = ({
  hostName,
  onUnhealthy,
}: {
  hostName: string;
  onUnhealthy: () => unknown;
}) => {
  useEffect(() => {
    // flag to indicate if this instance of useEffect is active
    let active = true;
    // again, an imaginary construct
    const httpClient = new HTTPClient();

    // run the healthcheck loop
    (async () => {
      while (active) {
        const res = await httpClient.get(`${hostName}/healthz`);
        const { status } = res;
        if (status < 200 || status > 299) {
          // signal to parent that the corresponding guest service is unhealthy
          onUnhealthy();
        }
        await sleep(INTERVAL);
      }
    })();

    return () => {
      // stops the loop
      active = false;
    };
  }, [hostName]);

  // null is a React.ReactNode too
  return null;
};

This example highlight how well React is doing as a machine tree framework even with the UI aspect thrown away. I think this is React's biggest asset: how good it is to help developers write a machine tree that is easy to look and move. The more movable the machines inside the tree, the easier it is to re-architecture the application in the event of change of business requirements.

Caveats & End-words

React is almost always enough for its users and end-users. It has its downsides, like the huge memory it eats on every renders. React is by nature costly, as JavaScript is too. Everything is stringly-typed, primitives cannot be passed by reference without inciting a fight between teammates, etcetera etcetera.

The main thing is: it is good enough. It is practical and productive as it is intriguing. The concepts, especially when compared to its competitors, are interesting: what it automates vs what it leaves for the programmer to handle, the API, the use of superset of JavaScript instead of a totally custom templating language, its reconciliation strategy, and the productivity boost the programmer gains. At least, my 5 years with React (the library) has been awesome!

How would one enhance these ideas? What if some of the machine-tree creation steps are pushed into compile-time ala Svelte? How would it look like if the machine-tree concept is written on top of a language that doesn't have a built-in event loop? The possibilities are vast.