valand.dev

From Rust to TypeScript: Lifetime Analysis for React Component Architecture

Edit (3 Feb 2022): Fixed a typo on the 2-frames image caption

I'm a bit cheating on this one's title since this is less about TypeScript and more but about React, better yet, React app empowered by TypeScript. Anyhow, let's get to the point.

Object lifetime has always existed in programming, though only recently it is tightly coupled as a programming constraint by non-GC programming language, in a form of C++'s RAII and the notoriously complex, Rust's ownership and lifetime analysis. it turns out, a similar analysis method is actually useful to architect React components, more precisely to determine which state goes where.

What are object, lifetime, and ownership?

Object, in my article, refers to any value in memory, so since we are going to talk in TS and JS world, anything is object, including, function, JS object, Symbols, number, BigInt, Map, etc.

Lifetime, or to be more precise, an object's lifetime is the period between the object's creation in memory and its destruction.

Ownership is a term I borrow from the Rust programming language to refer to the referability of a target object from a source object. That's complex! What I mean is, if you can get access to object B through object A, then A owns B. If object A has property B, A owns B. If function A has access to variable B, function A owns variable B. Unlike in Rust, JS does not have a mechanism to keep track of the original owner, so I'm keeping it simple.

A quick why...

In my workplace, I'm a part of a team that has been, for quite long, working on a complex project built mostly on TS with some C++ and Rust. It has equal React and non-React parts and is highly concurrent.

As we were going on the journey, I began to see two kinds of programmers in my team. For now, I'll refer them to the system-minded and the script-minded. The script-minded suits more on works closely related to client-side-ish, better suited to work on problems of implementations. The system-minded is more architecturally sensitive and suits more to work on specifications.

The system-minded devs are subconsciously aware of this ownership and lifetime design, but they may not have the exact glossary around them. They often write a good architectural review, which sometimes comes late because it is happening after implementation, not before, a case of imperfect change management. Anyhow, when challenged on why the big architectural changes must happen before the code is being merged into a long-running git branch, it is hard for the system-minded people to reach intelligible sentences that can convince the script-minded people.

This article, I wish, can serve as an easy heuristic that can explain the intelligence of lifetime analysis in intelligible sentences as well as help people in architecting a React app. Also, this article points out the cost from misarchitecture, however minuscule. Ultimately, it should relieve everybody from the potential frustration related to object ownership design in TS and React apps.

How does lifetime plays the important role?

It is pretty much impossible and useless to see the exact timestamps of an object's lifetime. However, comparing the lifetime of two related objects is extremely useful. These questions can be used to properly do lifetime analysis:

  • Compare object-A’s lifetime to object-B’s lifetime. Is it longer, equal, or shorter? (lifetime-A [> / < / =] lifetime-B)
  • If object-A’s lifetime > object-B’s lifetime. Compare object-A’s lifetime to struct-B’s (constructor of object-b) lifetime. Can two or more object-B relate to one object-A? (A has many or has one B)?
  • If two or more object-B relate to one object-A, is it happening at the same time? (parallel vs sequential)

Let's make this less abstract by examining an example. I am going to use a very simple case, which is a page and a modal in a video hosting site. There is no OS process or threading involved. All concurrent operations are treated as the same class here.

Example: here is a web app page specification:

  • Channel is a video author that is subscribable by a user
  • Channel Page is a page that lists all channels subscribed by a user
  • Channel Page has the "modify subscription" button that pops up the Edit Modal where the user can add or remove subscriptions to channels
  • Edit Modal list checkboxes of channels. Users can search channels in it and check or uncheck checkboxes. Checked checkboxes being the channel subscriptions. There are two buttons at the bottom of the modal, "apply" and "cancel". "apply" button is enabled whenever the user is making a change. Users can use those buttons to either save their changes or cancel their changes.
  • Each time Edit Modal is popped up, the search bar must be empty and the checked subscriptions must match the list of the de facto subscribed channels.
  • If "apply" is clicked, the subscription list is updated and the Channel Page updates its subscribed channels list. Both "apply" or "cancel" close the modal.

See the picture below as the requirement's illustration.

problem.png

The top frame represents the Channel Page. The bottom frame represents the Edit Modal. Let us first identify the business objects that are required to make the web page:

  • Two major React components:

    • Channel Page
    • Edit Modal
  • Five plain objects:

    • isModalOpen; boolean flag to determine if the modal is open;
    • currentSubscriptions; array of subscribed channels at current; shown as cards in the Channel Page; Used in tandem with newSubscriptions to be compared their equality to determine if "apply" button is enabled.
    • filter; object that describes the filter applied to the Edit Modal view; shown as a search bar in Edit Modal;
    • filteredChannels; channels that are filtered according to filter object; shown as a list of checkboxes;
    • newSubscriptions; array of channels that are going to replace the de facto currentSubscriptions; shown as checked checkboxes in Edit Modal.

The ultimate question of architecture in this problem scope is, "where should I put the plain objects"?

For now, let us examine the least optimal architecture, where all the plain objects are owned by Channel Page. Why the least optimal architecture? Because we want to see to what extent does an unoptimized architecture cost the code. This architecture should introduce a set of problems. A more optimal, but not the most optimal architecture should have a subset of the problems the least optimal architecture.

Examining the least optimal architecture that still makes sense

bad.png

In the least optimal architecture all plain objects are put owned by Channel Page. This introduces several issues:

  • Edit Modal modifies filter (on search), filteredChannels (on search), and newSubscriptions (on check / uncheck of checkboxes). This is a form of mutable shared ownership of these objects by both Channel Page and Edit Modal.

    • Mutable shared ownership is troublesome in JS because it creates ambiguity on who should have the authority to semantically create and delete the object.
  • To mutate the mutable shared objects, Channel Page needs to pass a callback to Edit Modal. In this case, Channel Page must pass three callbacks (or one that can modify three at different times) for Edit Modal to change what is owned by Channel Page.

    • In React, a component passes information upward (to the parent component) via callback function. It is often named onSomeEventName.
    • These callbacks are added in addition to two other mandatory callbacks, onSubscriptionUpdate and onCancel.
  • The function to open the modal, triggered by clicking the "modify subscription" button, other than setting isModalOpen to true, must also reset filter, filteredChannels, and newSubscriptions.
  • There is an introduced ambiguity about where to put the updateSubscription, the function that sets newSubscriptions as the new de facto subscribed channels. Since both Channel Page and Edit Modal have reference to newSubscriptions, should it be owned to Channel Page or Edit Modal?
  • There is an introduced ambiguity about whether on closing the Edit Modal, filter, filteredChannels. and newSubscriptions must be cleaned up.

    • On code review, one could argue against a request to add a clean-up code because when opening the modal, those objects are reset, either way, these objects are not used while the modal is not open. The burden on proof is on the change requestor.

How do we fix these?

Architecturing for less garbage!

How to rearrange the ownership of these objects to get rid of these negative impacts? Let's start a lifetime analysis. Let's ignore all functions and focus on the objects. We are going to need a table of the lifetime relationship between owner and ownees.

Owner (React component) Lifetime Relationship Ownee (Ownable plain object)
Channel Page = isModalOpen
Channel Page = currentSubscriptions
Channel Page > filter
Channel Page > filteredChannels
Channel Page > newSubscriptions
Edit Modal < isModalOpen
Edit Modal = currentSubscriptions
Edit Modal = filter
Edit Modal = filteredChannels
Edit Modal = newSubscriptions

Notes on lifetime relationship:

  • = lives equally long
  • > lives longer
  • < lives shorter

"Where did the lifetime relationship column came from?" you might ask. Here's an explanation.

  • All equally long-lived (=) relationship is because the owner needs the ownee to exist to operate.

    • e.g. Channel Page cannot show the subscription list if currentSubscriptions has not existed.
  • All longer-living (>) relationship is caused by the owner not needing the ownee to exist to operate.

    • e.g. Channel Page does not need filter, filteredChannels and newSubscriptions in order to show its list and toggle Edit Modal on and off.
  • All shorter-living (<) relationship is because the ownee needs to exist in order for the owner to exist, or to be determined to exist

    • e.g. Channel Page lives shorter than isModalOpen because isModalOpen determines if Edit Modal lives or not.

Now that we have the table, let’s do some processing on the table:

  1. Mark the shorter-living (<) relationship as unusable
  2. Mark the equally long-lived (=) relationship as a priority
Owner (React component) Lifetime Relationship Ownee (Ownable plain object)
Channel Page (good) = isModalOpen
Channel Page (dupe) = currentSubscriptions
Edit Modal (dupe) = currentSubscriptions
Edit Modal (good) = filter
Edit Modal (good) = filteredChannels
Edit Modal (good) = newSubscriptions
Channel Page > filter
Channel Page > filteredChannels
Channel Page > newSubscriptions
Edit Modal (unused) < isModalOpen

We can derive some info from this table:

  • Every owner-ownee record has an equal (=) lifetime relationship. Those are the owner-ownee pairs that we are going to use.
  • We have two owners for currentSubscriptions. I marked it as (dupe). For this case, we can opt for one of these things:

    • Channel Page and Edit Modal both own a reference to the same object.
    • Edit Modal has a copy of Channel Page's owned currentSubscriptions
    • Edit Modal acquires its copy of currentSubscriptions through other means.

For the sake of simplicity, for the duplicated ownership relationship, let’s choose the first one, immutable shared ownership.

What we have is this architecture:

good.png

What happens to the negative impacts from the previous non-optimal architecture?

  • Mutable shared ownership is no longer.
  • Channel Page does not need to pass unnecessary callbacks.
  • The function to open the modal only needs to set isModalOpen to true.

    • currentSubscriptionsis passed down as a prop to Edit Modal. In TypeScript enhanced React, props are enforceable by the compiler, an extra guard for the programmers.
    • new_channel_subscriptions is a copy of the currentSubscriptions
  • updateSubscription function can only be put in Edit Modal, thus no more ambiguity about it.
  • filter, filteredChannels, and newSubscriptions are automatically created and destroyed (reset) alongside Edit Modal, thus no more ambiguity about resetting those objects.

Still early

That example above is only an extremely simplified version if Rust's lifetime analysis applied to a very simple problem. It did not account for the existence of concurrent operations, concurrent function calls, only React components and plain objects. You can practically use this heuristic to derive other heuristics that apply to other types of objects, the serializable vs the non-serializable, promises, always unique, etc.

I'm also encouraging all of you to check on Rust's lifetime analysis, complete with its ownership and borrow analysis, just because those are very insightful toward software architecture in general.

Keep exploring, Rust, TS, other programming languages, other non-programming stuff, everything. It is still early.