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.
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 Link
s, 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.
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 ancestorRouter
component via the method it passed.Main
listens/subscribes to the nearest ancestorRouter
. Any change in theRouter
's state causesMain
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.
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.
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.