Recently, I jumped on the codebase of a small product of ours (an integration with Shopify) to implement some last-minute changes before going live. I had an overall idea of what the product does, and the external team that worked on it received:
comprehensive documentation of our coding patterns (they are publicly available in the “WorkWave RouteManager UI coding patterns” article)
thorough code reviews by our front-end architect (you can get an idea of how vital code reviews are for us in my “Support the Reviewers with detailed Pull Request descriptions” article)
Despite that, being effective since the first day was hard for me. Why? Because four of the most important details that get a codebase immediately readable were missing, which are:
TypeScript’ Discriminated Unions instead of optional properties (helpful to describe the domain)
Straightforward JSX code (ease reading and jumping through the code)
Explicit State Machines (describe what the app does from an internal perspective)
Cypress integration tests (tell what the app does from an external perspective)
I will explain why I weigh these four features in the following chapters.
1. TypeScript Discriminated Unions
Discriminated Unions (here is the link to the TypeScript documentation) are a potent tool to express why an object contains or does not have some properties. When applied to domain entities, they act as quick documentation that tells the readers about the domain entity they are dealing with. Here is an example
type Order = {
status: string
name: string
description?: string
at?: Location
expectedDelivery?: Date
deliveredOn?: Date
}
Most of the Order’s properties are optional. It is fine. Let us look at one of the possible consumers of the above Order type.
export function createEmailMessage(order: Order) {
if (order.expectedDelivery) {
return `${order.name} will be delivered ${order.expectedDelivery}`
}
if (order.deliveredOn) {
return `${order.name} has been delivered on ${order.deliveredOn}`
}
if (!order.expectedDelivery && !order.deliveredOn) {
return `${order.name} is at ${order.at}`
}
}
TypeScript helps us avoid accessing properties that could not exist, and we are safe.
What are we missing in the above example? The whys.
Are we sure
status
is a genericstring
?Why is
description
optional?Why is
at
optional? Are we managing Orders that could not have anat
Location? Such as services vs. goods?Why is
deliveredOn
optional? Their name suggests that the Order has been shipped. Do they depend on the Order’s status?
We cannot answer these kinds of questions without reading the product/domain documentation (if any) or without interrupting and boring our colleagues to understand when/why the mentioned properties exist or not. Even worse, this uncertainty pours on the code, causing a big mess in the codebase that gets worse and worse as it evolves.
Then, it turns out that the status
is not a generic string
but one of a finite set of states, such as 'ready' | 'inProgress' | 'complete'
. And the optional properties are not that optional, but they are bound to the Order’s status
. It all makes sense, but how much time do I spend to get this info? What if there are 20ish entities whose types are like the Order
one? It is what happened to me when I jumped on the project I mentioned earlier.
With the actual knowledge of the domain, let us refactor the Order
type by leveraging TS Discriminated Unions.
type Order = {
name: string
description?: string
at: Location
} & ({
status: 'ready'
} | {
status: 'inProgress'
expectedDelivery: Date
} | {
status: 'complete'
expectedDelivery: Date
deliveredOn: Date
})
Now the future readers not only know when the properties exist but also why! And it is a highly concise documentation of the Order
entity at the same time! I do not need to read anything but this type to get a precise idea of what the Order contains, and dealing with it gets a lot simpler. We moved from “oh, these properties are optional” to “oh, the order has three statuses, and when it is delivered, the delivery date is available”.
Do you remember the if-based createEmailMessage
function? Now it’s
function createEmailMessage(order: Order) {
switch(order.status) {
case 'ready':
return `${order.name} is at ${order.at}`
case 'inProgress':
return `${order.name} will be delivered ${order.expectedDelivery}`
case 'complete':
return `${order.name} has been delivered at ${order.deliveredOn}`
}
}
The returned message did not change, but understanding the correct message for every Order status is.
And the readers are not the only ones that benefit from such readability! TypeScript can better infer the returned type. Look at what TS can infer from the first version of the createEmailMessage
type and the second one.
It means that consuming createEmailMessage
turns from
const message = createEmailMessage(order)
if(message) {
sendEmail(message)
}
to
const message = createEmailMessage(order)
sendEmail(message)
Multiply these benefits for the number of your entities and the size of your codebase… Do you get why I care so much about self-explanatory types? 😊
In the end, in the first Order
type, we were missing the possibility of explaining the Order entity to the reader.
2. Straightforward JSX code
If I have not convinced you with the code of createEmailMessage
, look at a standard JSX code.
export function RenderOrder() {
const [order, setOrder] = useState<Order | undefined>()
useEffect(() => {
fetch('https://api.yourdomain.com/latest-order')
.then(response => response.json())
.then(order => setOrder(order))
}, [])
const onSendEmailClick = useCallback(() => {
if (!order) return
const message = createEmailMessage(order)
if (message) {
sendEmail(message)
}
}, [order])
if (!order) return null
return (
<div>
<p>
{order.name} ({order.status})
</p>
{order.description && <p>{order.description}</p>}
{!order.deliveredOn && order.expectedDelivery && (
<p>Expected delivery: {order.expectedDelivery}</p>
)}
{order.deliveredOn && <p>Delivered on: {order.deliveredOn}</p>}
<button onClick={onSendEmailClick}>Send email</button>
</div>
)
}
How many different DOM elements can result from the above JSX? Do you need less or more than five seconds to read it? Would you feel comfortable modifying it? And what about the React Hooks?
It is a simple example, and in a minute, you get an idea of whatever it does. But why should you waste this minute? And since it is just a simplified example, how long does it take to read a real-world component written like this?
The main problems are:
React Hooks are great, but they can make components’ code unreadable in no time.
The JSX handles too many cases. Building the states graph in your mind requires time.
My proposal is splitting the above component into many small components and custom hooks. Try reading the following code.
type FetchStatus =
| {
status: 'loading'
}
| {
status: 'success'
order: Order
}
export function RenderOrder() {
const fetchStatus = useFetchOrder() // Return FetchStatus
if (fetchStatus.status === 'loading') return <p>Loading...</p>
const order = fetchStatus.order
switch (order.status) {
case 'ready':
return <ReadyOrder order={order} />
case 'inProgress':
return <InProgressOrder order={order} />
case 'complete':
return <CompleteOrder order={order} />
}
}
type Props = {
order: Order
}
export function CompleteOrder(props: Props) {
const { order } = props
if (order.status !== 'complete') return null
const { name, description, deliveredOn } = order
return (
<div>
<OrderHeading name={name} description={description} />
<p>Delivered on: {deliveredOn}</p>
<SendEmailButton order={order} />
</div>
)
}
The crucial difference is clarity. You need more clicks to go down the rabbit hole to the exact component you are looking for, but clicks cost nothing when the code is straightforward. Instead, loading all the JSX states in our minds to guess what the component renders requires time and energy.
Please note: the if (order.status !== ‘complete’) return null
in CompleteOrder
is not optimal because we cannot add React hooks after the condition; hence we cannot have React hooks based on the properties of the completed Order. The problem, in this case, is that we know that the component will only receive a completed order, but TypeScript does not know. I don’t want to focus on this topic, but the quickest way to solve this problem is extracting a CompletedOrder
type from the Order
one:
type CompletedOrder = Extract<Order, { status: 'complete' }>
type Props = {
order: CompletedOrder
}
export function CompleteOrder(props: Props) {
const { order } = props
const { name, description, deliveredOn } = order
return (
<div>
<OrderHeading name={name} description={description} />
<p>Delivered on: {deliveredOn}</p>
<SendEmailButton order={order} />
</div>
)
}
3. Explicit state machines
Everything is a State Machine. As programmers, we create State Machines in our minds before writing code. On the opposite, understanding the State Machines by reading the code is complex. State Machines’ details are hidden into the small components, hooks, functions, routes, atoms, stores, etc., that compose our app. It is a problem for the reader that cannot distinguish the “smart” parts of the app from the “dumb”/presentational ones:
Who does read the initial data (a lot of times more components)?
How does the app move from one state to another one?
Who does change the state of the app?
Who does react to every state change?
In one question: how does the app work?
It depends on the app’s architecture, but usually, all the details of the State Machines that were in the author’s mind are not explicit. Even the apps rigorously based only on a global store could fail to explain how the app reacts to every state change (see the “Redux is half of a pattern” article).
Describing the whole app with a single State Machine is hard, but splitting the features in State Machines is crucial to ease the reader’s job. It is not important how we describe the State Machines, but having an entry point in the code where we explain most of the app/feature high-level flows is.
The first example comes from the app I cited at the beginning of the article. The side effects were spread all over the app, with many points setting the (Valtio-based) atoms, causing the app to re-render part or most of the UI tree. The things that can impact what the users see are:
If the users directly navigated to the app or the server redirected them, passing some data to the app
If the users connected the app to Shopify
If the users can view the app
If the users logged in
The intersection among the previous points
The React Hook that manages the state machine returns the following type (one status for every possible pages the users can see)
type AppStatus =
// initial value
| { status: 'idle' }
| { status: 'showLogin' }
| { status: 'showImport' }
| { status: 'showConnect' }
| { status: 'showNonAdminError' }
| { status: 'loadingUserSession' }
| { status: 'showGenericInstructions' }
| { status: 'showAllOrdersSelectedError' }
| { status: 'showSelectOrdersInstructions' }
And the State Machine is a big useEffect
composed by two nested switch
statements with a code like this
switch (currentPage) {
case 'connect':
switch (howUserNavigated('connect')) {
// ------------------------------------------------------------------
// SCENARIO: the server redirected the user to the connect page
// ------------------------------------------------------------------
case 'sentFromServer':
switch (connectStatus.status) {
case 'notRequestedYet':
case 'requesting':
case 'failed':
// when the connect succeeds, this effect is re-triggered
setStatus({ status: 'showConnect' })
break
case 'succeeded':
setStatus({ status: 'showSelectOrdersInstructions' })
break
}
break
// ------------------------------------------------------------------
// SCENARIO: the user navigated directly to the connect page
// ------------------------------------------------------------------
case 'directNavigation':
redirectTo('home') // as a result, this effect is re-triggered
break
}
break
You can argue that two nested switch
aren’t great, but I see value in a single file where the reader can understand everything about the high-level, domain data-driven pages management. You can find the complete code of the Hook in this Gist.
The next step is describing the State Machine through something made on purpose like XState. Here is an example from my recent “How I strive for XState machine, types, and tests readability” article.
There is nothing more explicit than a centralized and viewable State Machine. More: Xstate allows you to create a working and UI-free prototype in no time.
Thanks to explicit State Machines, the reader can see how your app/feature internally works.
4. Cypress integration tests
We spoke about code and never about the app from a user perspective. But there are some crucial topics to show to effectively onboard new developers:
Describing what the app does from a User perspective: I want to show the UI, the code doesn’t matter.
Describe the order of the user actions and the interactions with the back-end.
Working against a controlled back-end
Here Cypress comes in handy with its ability to stub the back-end and its expressive APIs. Here is an video and an example of the code behind the tests
it('When the server sends the users to the connect page, should show the "connect" page', () => {
visitAndResetSessionStorage('/connect?nonce=12345678')
// --------------------------------------------------------------------------------------
cy.log('--- Should bring to the connect page ---')
cy.findByRole('button', { name: 'Connect' }).should('be.visible').click()
// --------------------------------------------------------------------------------------
cy.log('--- Should show an error if connect fails ---')
cy.findByLabelText('Username').type('smagni', { delay: 0 })
cy.findByLabelText('Password').type('smagni', { delay: 0 })
cy.findByRole('button', { name: 'Connect' }).click()
cy.findByText('Something went wrong (Error -1)').should('be.visible')
// --------------------------------------------------------------------------------------
cy.log('--- Should show the import orders instructions if connect succeeds ---')
cy.intercept('POST', '**/shopify/connect', { fixture: 'importOrders/happyPath/connect' }).as(
'connect-request',
)
cy.findByRole('button', { name: 'Connect' }).click()
// --------------------------------------------------------------------------------------
cy.log('--- Should pass the user data to the server ---')
cy.wait('@connect-request').its('request.body').should('deep.equal', {
nonce: '12345678',
password: 'smagni',
username: 'smagni',
})
// --------------------------------------------------------------------------------------
cy.log('--- Should show the "select orders" instructions if connect succeeds ---')
cy.findByRole('button', { name: 'Select orders' }).should('be.visible')
})
By watching Cypress controlling the app and the app reacting to the interactions, it is straightforward to understand what the users can do and what they are not expected to do. The tests tell when the AJAX requests happen, thanks to the controlled server.
Conclusions
Now, I expect the next developers that must introduce a feature to:
Watch the feature-related tests to comprehend what the app does.
Read the feature-related tests to comprehend the data passed through the URL.
What data the app sends to the server and when.
What data the app receives from the server.
Read and understand the state machine behind the mentioned flows.
Quickly move through the React components.
Find all the domain-related knowledge in the types.
Blaming me because I don’t write code like them, but at least not spending roughly one week gathering all the domain knowledge I then made explicit through the code, the State Machine, and the tests 😊.
There are other important things to know for a long-living project, but the four I described are essential, in my opinion 😊.
Top comments (4)
Hey Stefano, thank you for this insightful post! I agree with about all the features 😊
Regarding to TypeScript's type discrimination - I really like it and I try using it wherever possible. I find it a bit more difficult adding it to existing code, since it usually requires a broader refactor. Do you have tips and tricks of how to approach this issue?
Honestly: no. Things are particular hard when the possible values come from the server so the investigation process could take a lot... But I don't know shortcuts here 😞
amazing post!!!
I'm glad you liked it ❤️