The more information we have up front when we’re building a software product, the better it will come out. But I’ve only heard legends about the days when engineers got a detailed document describing a project in detail without any follow-up changes.
Structuring your code properly is not as easy when the ground is shifting under your feet.
Modern applications are never set in stone. They start, small and simple, then slowly grow into a complex system with more and more features that rarely fit into our initial model. During that evolution we pile up small design mistakes and tech debt, eroding the state of the project.
That’s why I won’t show you how to build an app that’s frozen in time.
We’ll start small and build a very simple feature, imagining that a startup wants to validate an idea. Then, in the next few chapters, we’ll add more and more functionality to the product, expanding it and dealing with the curve balls that real life throws at us.
Building this functionality won’t be a challenge to any developer who’s had a little bit of experience, but structuring it correctly is a tall order even for experienced engineers. We need to know when to focus on design and when to intentionally avoid it.
We’ll establish the fundamental code-level principles we’ll follow to build our application. We’ll see how they will support us when we start changing things in future articles, then we’ll expand to project-level principles and even patterns used to build complex systems.
But one thing at a time.
A Quick Aside
This is not just an article full of code examples, it’s a chapter of my upcoming book “The Full-Stack Tao” which I’m writing in public.
Here are all published chapters so far:
- 1. Start with the Domain
- 2. Picking a Tech Stack
- 3. Setting Up the Project
- 4. Clean Architecture in React
- 5. Building a Proper REST API
- 6. How to Style a React Application
The Requirements
We have the following requirements:
“We need to build a page that allows users to read the currently active prompt in a writing app, and then answer it. They should be able to see other users’ answers, but only after they submit their own. Before they do, they should only see the prompt and the input field to submit their response.”
Simple enough, right?
We can put our headphones on and get to building. But if we do that without asking questions, we’ll hit all the unknown unknowns when we’ve already made some assumptions about the application.
How will we access the data? Since we have authenticated users, then what should anonymous users see? How will we authenticate users? How important is SEO? Will there be a mechanism for users to edit or delete their responses after submission? Will there be any animations on the page? Where will we host the images that we will show?
Don’t Build on Assumptions
Assumptions are the root cause for many engineering problems and we want to make as few of them as possible. We can never do completely without them because we don’t operate with perfect information about the future, but the more questions we ask the fewer of them remain.
Sometimes the people sitting on the business side will have an answer, sometimes they won’t. But by drilling down, we reduce the chance of critical design mistakes.
By asking questions again and again, we shift from a paradigm of ‘best-guess’ engineering to one of informed decision-making.
A First Draft
Once we get a better understanding of the product, it’s important to get something on the screen as soon as possible. Regardless if you’re working in a startup or a big corp, we need to get the application to work before we start thinking of any improvements, patterns, and structure.
Think about it as the first draft of a text. We don’t sweat the grammar, the commas, and the repetitive words. We just want to get it working.
In the context of React, this would mean creating a component, putting all the functionality inside of it, and making sure it matches our expectations.
export default function App() {
return (
<div>
<header>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/contact">Contact</Link>
</li>
</ul>
</nav>
</header>
<main>...</main>
</div>
)
}
I’ve intentionally omitted the styling details here because we’ll focus on them in a follow-up chapter.
We get to the point where we need to decide what to render on the page. Our decision will be based on whether the user has answered the current prompt, and we have multiple ways to retrieve that info.
Since we don’t have auth implemented now and we’re just prototyping the UI we need to think in the realm of the abstract here.
Discussing Data Retrieval
We could make one request to retrieve the prompt and answers for the page, and another to check if the user has answered. Filtering through the answers in the browser is out of the question - a pagination mechanism is likely to be used for such content. We shouldn’t overfetch data we won’t show on the screen and it wouldn’t be secure to have data in the browser we wouldn’t want the user to see.
Since we can’t derive that information from the response consistently, and we’d need it together with the rest of the data, our only option would be to have the server do it for us.
Another way the API could give us this information would be to send null
as the value of the answers
property, signaling that we shouldn’t show them. But we don’t want to communicate with types and rely on convention. It would mean a lot of additional checks for a nullable type in the front-end, and it would also have to be documented on the back-end.
In other words, by using null
for communication we risk putting ourselves into a Chesterton’s Fence situation. Someone may change it to an empty array in the future, not knowing that we rely on the empty type and we’d end up with a broken product.
We’d be using null
as a flag, and we can do better by having the API just send a boolean flag instead as a separate property called answered
. While prototyping and waiting for the server to make the data available, we could work with the following structure.
const prompt = {
title: 'What is the meaning of life?',
answers: [],
answered: false,
}
Then we can have a check in the component based on that flag.
export default function App() {
// ...
return (
// ...
<main>
{prompt.answered ? (
prompt.answers.map((answer) => <div>...</div>)
) : (
<form>
<textarea></textarea>
<button>Submit</button>
</form>
)}
</main>
//...
)
}
Next, we need to think of the structure for the answers.
const prompt = {
title: 'What is the meaning of life?',
answers: [
{
text: '42',
author: {
name: 'John Doe',
},
},
],
answered: true,
}
The only important detail here is how would the author’s info be passed together with the answer. This has to be communicated with the team/person implementing the back-end, just like we thought about the boolean answered
flag above.
In this case, I’d rather have a nested property for the author instead of keeping their data at the same level as the rest of the answer data.
export default function App() {
// ...
return (
// ...
<main>
{prompt.answered ? (
prompt.answers.map((answer) => (
<div>
<p>{answer.text}</p>
<div>— {answer.author.name}</div>
</div>
))
) : (
<form>
<textarea></textarea>
<button>Submit</button>
</form>
)}
</main>
)
}
For now, we’ll skip styling and forms because we want to focus on architecture. I start every implementation with hardcoded data because it allows me to focus on business logic immediately instead of working on networking.
But once I have an idea of how I want to use the data, it’s time to integrate it into the app’s flow.
Designing the Interface First
I had a period in my career where I actively worked with NoSQL databases like DynamoDB.
We’ll get into the nitty gritty details of databases later in the book but there was one epiphany I had during that time.
Working with this kind of store made us think very carefully about the access patterns we were going to have in the application because retrieving data from them was only efficient if we could take advantage of their indexes.
So every product was designed based on its access patterns. Sometimes this was the UI, other times it was an API endpoint, but the user-centric focus during the development resulted in an easier-to-understand system, built around the domain specifics. As a side-effect, everything was built in order to support the exact use cases we had.
So I took this practice to heart and started applying it for all products I built from scratch, regardless of the underlying databases they had to use - I designed the user-facing part first.
Build for the Domain
Every product needs to be built for its domain, not based on generic principles. Technical architectures don’t exist out of context. To decide how we’re going to access our data, we need to follow our access patterns.
If we were implementing this feature with a focus on technical conventions rather than business logic, we’d end up with the following REST API
/prompts
/prompts/:id
/prompts/:id/answers
We’d need a way to retrieve the current prompt ID in the UI, by sending a request to /prompts?active=true
, for example. Then we’d fetch all the data about the prompt from its specific URL, then if the user has answered it, we’d get the answers from the last endpoint.
This follows RESTful conventions and considered in isolation is a technically sound implementation. The REST API has a clear boundary between entities and how they’re retrieved. But when you take the product as a whole, this purism will result in a worse overall experience for the user and a lot of complexity that doesn’t need to be there.
Users will have to wait on at least two subsequent requests to get the complete content of the page, even though it could’ve been retrieved with one.
If you don’t start from the user-facing part of the product it will have to compensate for its inability to fetch data competently. That’s how you end up with a GraphQL layer solving your overfetching problems, and a tonne of avoidable complexity on the side.
We want to send a request to /prompt
with an identifier for the user and let the back-end take care of retrieving the user, checking if they have answered, and returning the appropriate data.
Purism Leaks Logic
However, there’s an additional factor here - the front-end becomes the driver of the business logic. By keeping the API pure and generic we only decide that the decision-making will have to be done elsewhere.
With this flawed design, the responsibility will fall entirely on the client - it will have to decide when and what to fetch and it will be aware of all the functionality of the RESTful API.
The back-end should decide what to return, and the front-end should decide what to render with the data it has. This is the single responsibility principle in its purest form.
This is the kind of thought process you’d need to go through when you’re communicating requirements with your colleagues working on the back-end or even when you’re the solo developer on a project. Think in advance with the information you have or you’ll be piling tech debt from the start.
Adding a Data Source
Now that we know how we want to fetch the data, we can replace the hardcoded data with an HTTP call and add a default placeholder so we don’t have to think about empty states for now.
const emptyPrompt = {
title: '',
answers: [],
answered: false,
}
export default function App() {
const [prompt, setPrompt] = useState(emptyPrompt)
useEffect(() => {
fetch('http://localhost:3000/api/v1/prompts')
.then((res) => res.json())
.then((data) => setPrompt(data))
}, [])
// ...
}
And with that, we have everything in place for a working component.
If you’re just validating an idea, prototyping a new interface, or exploring a new technology this is a fine place to stop. If you’re working in a startup hard-pressed for time, crunching before a demo to secure your next round, that’s as good as you need it. We can slap an integration test on top of it and call it a day.
But in all other cases, a working component is just the first step in building a feature. We have our first draft, now we need to edit it.
Tidying up
I don’t like the term refactoring because it’s become pretty loaded in modern software development. It’s synonymous with stopping product work for a week (at best) so you can ponder your implementation’s design, moving logic up and down, extracting functions, and establishing modules.
But that’s a hard idea to sell.
Software design shouldn’t be something we think about six months into the project when people start complaining and we run out of excuses to ignore it. Later in the book, we’ll talk about what to do if we inherit an application where design is an afterthought, but if you edit and design as you go, you’ll never reach that point of no return.
This is our component so far.
export default function App() {
const [prompt, setPrompt] = useState(emptyPrompt)
useEffect(() => {
fetch('http://localhost:3000/api/v1/prompts')
.then((res) => res.json())
.then((data) => setPrompt(data))
})
return (
<div>
<header>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/contact">Contact</Link>
</li>
</ul>
</nav>
</header>
<main>
<h1>{prompt.title}</h1>
{prompt.answered ? (
prompt.answers.map((answer) => (
<div>
<p>{answer.text}</p>
<div>— {answer.author.name}</div>
</div>
))
) : (
<form>
<textarea></textarea>
<button>Submit</button>
</form>
)}
</main>
</div>
)
}
The first thing we notice is that it has too many responsibilities - it knows the whole structure of the page, the navigation, and the contents. It makes the decision about what to render, manages the state, and does the data fetching.
Every time we need to make a change to this component, we’d have to get familiar with all its responsibilities, and built like this, they will only increase with time.
Layout
When we start adding more pages to this application, we will quickly notice that the structure will be duplicated for each one. Pages will also need meta tags, a title, context providers, and other configuration.
Normally, I never rush to extract reusable components. But in this case, we know that we will need some way of avoiding all this repetitiveness so it’s best to make a layout component as soon as possible.
export default function Layout({ children }) {
return (
<div>
<header>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/contact">Contact</Link>
</li>
</ul>
</nav>
</header>
<main>{children}</main>
</div>
)
}
This is better, but if we need to add some logic to the navigation, checking if the user is authenticated, for example, we’d need to implement that in the layout. Again, this means that our layout has more responsibilities than providing the skeleton for the page.
So we need to extract another component.
export default function Layout({ children }) {
return (
<div>
<header>
<Navigation />
</header>
<main>{children}</main>
</div>
)
}
That’s better. Maybe you’re wondering why the <header>
element is not added in the navigation itself. That’s because deciding where to render the navigation is the responsibility of the layout. In a different layout, the navigation may be on the side instead.
The application doesn’t need to have a single layout component covering all its pages. You can have as many as your design demands.
An e-commerce application with a sidebar used for filters and a main part can have a MainWithAsideLayout
. Then it could have a OneColumnLayout
for its much simpler checkout flow. And it could have an admin panel where the navigation is on the side.
For now, we only need one. No need to guess that far into the future.
The Page
After extracting the layout and giving our component a better name, our page looks like so.
export default function PromptPage() {
// ...
return (
<Layout>
<h1>{prompt.title}</h1>
{prompt.answered ? (
prompt.answers.map((answer) => (
<div>
<p>{answer.text}</p>
<div>— {answer.author.name}</div>
</div>
))
) : (
<form>
<textarea></textarea>
<button>Submit</button>
</form>
)}
</Layout>
)
}
Components are just functions, and as such, they follow the same design principles we use to structure functions. In a regular function, if you have lengthy conditional statements you would extract them to reduce the cognitive load.
In the same way, every time I see a conditional in JSX, I consider if I can remove or simplify it to reduce the noise in the component.
Here we have a very simple check so we don’t need to think about abstracting it.
Instead, we should move the markup to two separate components because they will both grow in size. Every answer will need to be styled, and keeping that logic inside a component that has nothing to do with it doesn’t make sense. The form, on the other hand, will need to be handled, validated, and styled too - keeping all that state and additional logic adds more unneeded responsibilities to the page component.
export default function PromptPage() {
const [prompt, setPrompt] = useState(emptyPrompt)
useEffect(() => {
fetch('http://localhost:3000/api/v1/prompts')
.then((res) => res.json())
.then((data) => setPrompt(data))
})
return (
<Layout>
<h1>{prompt.title}</h1>
{prompt.answered ? (
<AnswersList answers={prompt.answers} />
) : (
<AnswerForm />
)}
</Layout>
)
}
I find this to be readable enough to continue forward.
Fetching Data
I’ve noticed that most of the architectural effort in the front-end is focused on creating abstractions around the HTTP layer. Developers understand boundaries and responsibilities intuitively and know that this logic has no place with the visualization logic.
This is what we’ll do next.
The component shouldn’t be aware if we’re using fetch
, axios
, or another HTTP client to retrieve data. It shouldn’t be aware if that data is fetched via HTTP, gRPC if it comes from a web socket or any other kind of source. It should only be responsible for rendering that data. And the developer making changes to this component doesn’t need to read through all of our HTTP logic to work on the markup.
So we’ll move the data fetching out in a separate file, and export it as an object with methods on it.
// prompt-client.ts
const getActivePrompt = () => {
return fetch('http://localhost:3000/api/v1/prompts').then((res) =>
res.json()
)
}
export default {
getActivePrompt,
}
And if our server is not ready to provide us with the data yet, we can hardcode it here instead of the component so it doesn’t ruin the natural flow of work. To the rest of the application, the data will be returned by the client - it won’t impact their design. Then the change would only have to be done here.
Once we’ve done that, we’ll call that function from our useEffect
export default function PromptPage() {
const [prompt, setPrompt] = useState(emptyPrompt)
useEffect(() => {
promptClient.getActivePrompt().then((data) => setPrompt(data))
})
return (
<Layout>
<h1>{prompt.title}</h1>
{prompt.answered ? (
<AnswersList answers={prompt.answers} />
) : (
<AnswerForm />
)}
</Layout>
)
}
We hide the transport layer, removing the request details, and the client from the body of the component.
Data fetching implicitly adds more complexity, though. We don’t know how long it will take to retrieve the data, and if it would even be returned successfully. In our current state, if the server fails for some reason, we have no way to communicate this to the user besides a blank page.
export default function PromptPage() {
const [prompt, setPrompt] = useState(emptyPrompt)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
setIsLoading(true)
promptClient
.getActivePrompt()
.then((data) => setPrompt(data))
.catch(() => setIsError(true))
.finally(() => setIsLoading(false))
}, [])
// ...
}
If we take a brief glimpse into the future, we will uncover even more communication-related logic we need to take care of. When the user submits the AnswerForm
they will need to see the answers, but since they’re not present in our initial request, we will have to refetch it. We’ll also need to communicate loading and error states so the UI doesn’t look frozen while the submitted form is still en route.
export default function PromptPage() {
const [prompt, setPrompt] = useState(emptyPrompt)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const getPrompt = () => {
setIsLoading(true)
promptClient
.getActivePrompt()
.then((data) => setPrompt(data))
.catch(() => setIsError(true))
.finally(() => setIsLoading(false))
}
useEffect(() => {
getPrompt()
}, [])
return (
// ...
<AnswerForm
onSubmit={(answer) => {
promptClient.createAnswer(answer).then(() => getPrompt())
}}
/>
// ...
)
}
Modern interfaces are dynamic, requiring constant communication with one or many APIs based on user actions. To this day, this is one of the biggest sources of complexity, and if you take a look at any application from the dawn of modern front-end development, you will see lines upon lines of code all dealing with request states.
It’s surprising how much of a UI’s state management logic is related to data fetching.
Nowadays there are useful data fetching abstractions that greatly simplify this logic, hiding many of the details from our application, and giving us a handy API to work with. I rarely advocate for the early usage of libraries in any project, but having something to make the bridge between the transport layer and the component lifecycle is a blessing - it removes a lot of verbosity from the codebase.
export default function PromptPage() {
const queryClient = useQueryClient()
// Fetching prompt data
const {
data: prompt,
isLoading,
isError,
} = useQuery({
queryKey: 'prompt',
queryFn: promptClient.getActivePrompt,
initialData: emptyPrompt,
})
// Handling answer submission
const mutation = useMutation({
mutationFn: promptClient.createAnswer,
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries('prompt')
},
})
const handleSubmit = (answer) => {
mutation.mutate(answer)
}
return (
// ...
<AnswerForm onSubmit={handleSubmit} />
// ...
)
}
The details of the library’s API are not our focus since they are subject to change. What’s important is that our component keeps no state on its own, and we have removed the effect. We’ve traded the imperative API for a more descriptive one.
But one design problem that still remains is that anyone who has to work on that component has to understand the data fetching logic. The component is still aware of where the prompt is, when it’s refetched, and how answers are stored. In its ideal state, a component should just receive data and return markup - it should act as a pure function. The visualization code is our interface to the browser. When, how, and what data we need is part of our domain logic.
They have no place being together, so we need to figure out a way to move one of them away from the other.
In the context of React, custom hooks are a useful mechanism to approach this. They are an idiomatic feature of the library so they look natural in the code, yet they give us the necessary abstraction.
export default function PromptPage() {
const { prompt, handleSubmit } = usePrompt()
return (
<Layout>
<h1>{prompt.title}</h1>
{prompt.answered ? (
<AnswersList answers={prompt.answers} />
) : (
<AnswerForm handleSubmit={handleSubmit} />
)}
</Layout>
)
}
Even though I don’t like the term “clean” because of its subjectivity - I’d call this a clean component. It has a single responsibility - render content on the screen based on the data it receives. Pay attention to how many details our custom hook is hiding from the component. It’s not aware if it’s coming from an HTTP call if it’s hardcoded, or read from a file.
On the other side, our business logic sits tidy in one place, no longer riddled throughout the component.
export default function usePrompt() {
const queryClient = useQueryClient()
// Fetching prompt data
const prompt = useQuery({
queryKey: 'prompt',
queryFn: promptClient.getActivePrompt,
initialData: emptyPrompt,
})
// Handling answer submission
const mutation = useMutation({
mutationFn: promptClient.createAnswer,
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries('prompt')
},
})
const handleSubmit = (answer) => {
mutation.mutate(answer)
}
return {
prompt,
handleSubmit,
}
}
This shows how much of the logic inside a component is actually used in the markup, and how much of it can be considered an implementation detail. Our component was aware of all this even though it needed only a fraction of it.
Shallow and Deep Modules
The usePrompt
hook is a great example of a deep module. Imagine it like an iceberg - it has a small visible surface, the returned object, and a large chunk of code that remains hidden. Deep modules allow us to control a lot of functionality through a small API which makes the abstraction useful.
A shallow module, on the other hand, exposes too many of its internals with a large surface area and it hides few details. It matters little if we use a shallow module or not since inlining the functionality would be similar in complexity. When you notice that you have a shallow abstraction somewhere, consider inlining its functionality to simplify your code.
A good question is why the submission handler has to be abstracted away too. After all, it’s related to the vizualization logic, shouldn’t it be inside the component?
const { prompt, handleSubmit } = usePrompt()
What is actually part of the UI is the event that something has happened - a button got clicked, a form submitted, an element hovered over. That’s what the component needs to handle. What happens as a result of that event is a matter of business logic. Because of that we only let a reference to the function that has to be called inside the component, the rest is logic that’s not tied to the browser.
An HTTP Layer Detail
While this is not front-end specific, I want to highlight the structure of our HTTP layer in the face of the promptsClient
. There is no clear-cut way about implementing a client so we’re free to do it with an object, a class, or a set of functions exported together in a single file.
Usually, I go with the latter.
// prompts-client.ts
export function getActivePrompt() {
return axios.get('https://example.com/api/v1/prompts')
}
export function createAnswer(answer) {
return axios.post('https://example.com/api/v1/prompts', { answer })
}
There is little value in using a more complex structure since there’s neither data nor behavior that we need to hide or reuse. A class would only add additional complexity to what’s otherwise a very simple implementation. The file gives enough cohesion as it is, and not using an object or a class means we can keep our indentation level to a minimum.
Another detail worth noting here is that most HTTP clients allow you to create a pre-configured instance for easier use when you need to hit multiple routes on the same API. The specific syntax is irrelevant, but it would look something like this:
// prompts-client.ts
const api = axios.createInstance('https://example.com/api/v1')
export function getActivePrompt() {
return api.get('/prompts')
}
export function createAnswer(answer) {
return api.post('/prompts', { answer })
}
This makes the bodies of the different functions a bit smaller, saving you space on the screen, and protecting you from gnarly copy-pasting errors that can send you on a two-hour-long bug chase before you find you have forgotten to add /v1
to the URL.
We will deal with user tokens and authentication in one of the next chapters and revisit this file.
Hardcoded Values
Any time we’re hardcoding a string in our code we should consider whether extracting it to a variable would make our code easier to read and maintain. A descriptive variable would give more meaning to the implementation than domain-specific text, for once. But more importantly, hardcoded strings are usually used for checks or they need to match something outside the boundaries of our application.
In the context of our HTTP layer, these string values represent the base URL and endpoints of an API, and keeping them inside the client represents an opportunity for errors (with forgotten leading slashes, for example). My approach is to extract these values in a separate object in a file that lives close to where the values are used.
// prompts-client.ts
const api = axios.createInstance(endpoints.baseURL)
export function getActivePrompt() {
return api.get(endpoints.activePrompt)
}
export function createAnswer(answer) {
return api.post(endpoints.createAnswer, { answer })
}
// endpoints.ts
export default {
baseURL: 'https://example.com/api/v1',
activePrompt: '/prompts',
createAnswer: '/prompts',
}
This looks a lot better.
The fact that we have repetitive values inside this object shouldn’t bother us since they are all kept together and the property name gives them a different meaning. What’s important is that we give meaning to them through the object they’re now a part of.
But if we look around at our code, we’ll notice that we have other hardcoded values in the data fetching hooks we use.
// usePrompt.ts
const prompt = useQuery({
queryKey: 'prompt',
queryFn: promptClient.getActivePrompt,
initialData: emptyPrompt,
})
const mutation = useMutation({
mutationFn: promptClient.createAnswer,
onSuccess: () => {
queryClient.invalidateQueries('prompt')
},
})
Every query has a unique key associated with it. It’s used to identify the value in an in-memory cache and invalidate it when we want it refetched. It’s easier to keep track of the hardcoded values when they’re next to each other in the same file, but we might have queries in different files that need the same data.
Then, depending on hardcoded strings would expose us to the problems we mentioned above.
// usePrompt.ts
const prompt = useQuery({
queryKey: queryKeys.prompt,
queryFn: promptClient.getActivePrompt,
initialData: emptyPrompt,
})
const mutation = useMutation({
mutationFn: promptClient.createAnswer,
onSuccess: () => {
queryClient.invalidateQueries(queryKeys.prompt)
},
})
// query-keys.ts
export default {
prompt: 'prompt',
}
With this, I think we’ve done a good job cleaning up our components.
Follow Evegreen Patterns
So far we’ve leveraged some evegreen programming advice in the context of a React application.
We’ve implemented layers, similar to how this is done in hexagonal architecture. We’ve split our single large component into smaller ones, similarly to how this is done with regular functions. And we’ve focused on making sure everything has a single responsibility, following the principle with the same name.
Most importantly, we did that without following complex design patterns to the letter. We took inspiration from the ageless principles that have proven to work in the industry and found a way to implement them in the context of our product.
All this is meant to illustrate that the technology we work with only adds a flavor on top of the same problems we’ve been dealing with for a long time as an industry. Also, front-end development is not a field where complexity has to be managed in a unique way. We already have the knowledge, we just have to find the best way to implement it.
Software Design Must be Pragmatic
When we think about software design we focus on making things look better. We marvel at a freshly refactored implementation, enjoying how readable and clean everything is now that we’ve tidied it up. But it’s important to understand that we don’t go through all this effort just to please our desire to Marie Kondo a codebase.
A good structure has pragmatic benefits.
We add layers because different kinds of logic change at a different pace, and they shouldn’t impact one another. The reader doesn’t have to know how the whole application works to make a change that only impacts one of its layers.
Our HTTP layer will be the least changed part of the application. Once all your handlers are in place, you’d be working predominantly on visualizing and working with the data they retrieve. And changes in the JSX will be a lot more common than changes in the domain logic that underpins them.
The code looks good as a side effect of us making sure it’s maintainable.
Dealing with Change
Every implementation can be structured well if it’s set in stone and never changes. Let’s see how we can approach follow-up requirements with this design. Imagine that we now need to add a timestamp to each answer, showing what time ago it was added.
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
export default function AnswersList({ answers }) {
return (
<div>
{answers.map((answer) => (
<div>
<p>{answer.text}</p>
<div>— {answer.author.name}</div>
<div>{dayjs(answer.createdAt).fromNow(true)}</div>
</div>
))}
</div>
)
}
With this, we have the relative time displayed on the screen. But remember that the first draft is just proof that what we want to create is possible.
We’ve found a useful lightweight library to work with dates, we’ve found the proper plugins and methods, and we’ve inserted it in the right place. We can play around with the styles too, to make sure that we don’t mess up our layout.
But we can’t ship our first draft.
This implementation ruins our layers, putting domain logic in representational logic. The component should only specify what and where to render, it shouldn’t be responsible for structuring data, let alone be aware of the libraries we use.
We want our component to look like this.
export default function AnswersList({ answers }) {
return (
<div>
{answers.map((answer) => (
<div>
<p>{answer.text}</p>
<div>— {answer.author.name}</div>
<div>{answer.createdAt}</div>
</div>
))}
</div>
)
}
Where would be the best place to abstract this logic? One option would be to do it in the promptsClient
after we fetch the data, but it’s not really the responsibility of the transport layer to structure data. This is a business requirement and as such it should be handled where we handle the rest of our domain-specific logic - in the custom hook.
export default function usePrompt() {
// ...
return {
prompt: {
...prompt,
answers: prompt.answers.map((answer) => ({
...answer,
createdAt: dayjs(answer.createdAt).fromNow(true),
})),
},
handleSubmit,
}
}
That’s one way to go about it, and we can add React.useMemo
if we need it. But these kinds of data manipulations using the spread ...
operator can become quite messy and make the rest of the code hard to read.
We could move the formatting out to a separate function, keeping the return
statement simpler.
export default function usePrompt() {
// ...
return {
prompt: formatPrompt(prompt),
handleSubmit,
}
}
We pass in the whole object otherwise we’d still have to construct an object here.
And while this is enough of an abstraction and it keeps both our component and hook simple, we could make one better and see if the libraries we use offer an API that would improve our design here.
// usePrompt.ts
const prompt = useQuery({
queryKey: queryKeys.prompt,
queryFn: promptClient.getActivePrompt,
initialData: emptyPrompt,
select: (prompt) => formatPrompt(prompt),
})
Or even simpler.
// usePrompt.ts
const prompt = useQuery({
queryKey: queryKeys.prompt,
queryFn: promptClient.getActivePrompt,
initialData: emptyPrompt,
select: formatPrompt,
})
// ...
return {
prompt,
handleSubmit,
}
The whole change takes no more than a line inside the custom hook, and a simple formatting function.
Even More Changes
Most often, our code ends up in a bad state because of our inability to incorporate all the changes it’s subject to in a sensible way. These changes are a result of shifting business requirements or lack of clarity when a new feature is being implemented. We discussed that this problem can be somewhat alleviated by continuously asking questions.
But another source of changes is us, the engineers, overlooking something that could cause a problem in the future, forcing us to deal with the fallout.
Our application has one such flaw.
We rely on the back-end returning a response with a certain structure, but let’s imagine that there was a problem with our REST API and it sent us a faulty object. We have no error handling, and that would naturally result in a broken UI. Now, handling an exception is not the hardest technical challenge, but deciding where to do it is a design decision we need to make consciously.
We could add checks in the components displaying the data:
export default function AnswersList({ answers }) {
if (!Array.isArray(answers)) {
return null
}
return (
// ...
)
}
But the more complex our application grows the more of these checks we’d have to add to our components. Each one of them would have to deal with validating its data potentially repeating a lot of validation logic. We want to move these checks up to a higher level.
export default function PromptPage() {
const { prompt, handleSubmit } = usePrompt()
return (
<Layout>
<h1>{prompt.title}</h1>
{prompt.answered && Array.isArray(prompt.answers) ? (
<AnswersList answers={prompt.answers} />
) : (
<AnswerForm handleSubmit={handleSubmit} />
)}
</Layout>
)
}
Pulling it one level up would mean that the child components no longer need to worry about the validity of the data, but we do increase the complexity in the parent component, making us reach toward nested ternaries to express our logic.
But as we mentioned, a component with many conditionals inside of it is a symptom of bad design. Just like a regular function.
Let’s go back to the fundamental design principles.
You will always look for a way to split up a function with too many conditional statements. It makes the code too complex, hard to read, and the mix of responsibilities means that it will be hard to maintain as well.
We need to pull these checks even higher and handle them at their origin. In our case, this is the transport layer.
export function getActivePrompt() {
const { data } = api.get(endpoints.activePrompt)
if (!Array.isArray(data.answers)) {
// ...
}
return data
}
We can add a check for the specific value that failed, but we have no guarantee that we won’t have a problem with another value in the future. So it makes sense to check all of them and validate that all of them correspond to the types we use in our application. But for a large response object or a deeply nested one, this would be burdensome to do by hand. Handling edge cases properly would leave this implementation prone to errors too.
Instead, we should put a schema validation library to use.
// schemas.ts
export const promptSchema = z.object({
title: z.string(),
answers: z.array({
text: z.string(),
author: z.object({
name: z.string(),
}),
createdAt: z.string().date(),
}),
})
// prompts-client.ts
export function getActivePrompt() {
const { data } = api.get(endpoints.activePrompt)
return promptSchema.parse(data)
}
We don’t have to write imperative logic, relying on a descriptive model of the data we expect.
Validating data at the trust boundaries is something we should do regardless if it’s at the front-end or back-end. Any time we’re accepting data from an external source we need to ensure that it matches our expectations or we’d be building on assumptions. In later chapters, we’ll do this when accepting requests in our API, reading messages from a message broker, or reading files.
Don’t settle for false security.
I’ve seen a lot of TypeScript projects written without validation in the transport layer, relying on casting the returned value to a type. But without any runtime checks for that value, you’re in the same spot in which we were a moment ago. If the API returns a faulty response, your application won’t be able to handle it regardless of your types.
Empty States
While we’re on the topic of handling errors, there’s one detail that we’ve overlooked so far. The moment we introduce the network into our application, we implicitly add a mountain of unavoidable complexity. Right now we avoid that problem by having a default empty prompt state, but let’s imagine that our product team decides to display an inspirational writing quote while the content on the page is loading.
We will remove the initial data, and the value will be undefined
before the prompt is loaded.
While working with hardcoded data we can rely on it always being available - the object sits right there in memory. But networks add a level of uncertainty that we can’t ignore. We already handled the errors, but there are empty states to think about. What do we show on the screen while we’re waiting for the data to arrive?
There are various loading indicators like messages, spinners, skeleton loaders, and in our case a quote - that’s mostly a product decision. But empty states add more complexity to the codebase.
Where we had straightforward templating, now we need another check.
export default function PromptPage() {
// ...
return (
<main>
{prompt ? (
prompt.answered ? (
<AnswersList answers={prompt.answers} />
) : (
<AnswerForm />
)
) : (
<RandomInspirationalQuote />
)}
</main>
)
}
But when we find that we have nested conditionals inside a component’s markup (and that goes for any framework, not just React), it means the component is not split granularly enough. A nested conditional is an opportunity to extract a child component.
// PromptPage.tsx
export default function PromptPage() {
// ...
return (
<main>
{prompt ? (
<PromptContent prompt={prompt} />
) : (
<RandomInspirationalQuote />
)}
</main>
)
}
// PromptContent.tsx
export default function PromptContent({ prompt }) {
return prompt.answered ? (
<AnswersList answers={prompt.answers} />
) : (
<AnswerForm />
)
}
But we don’t want to just sweep the complexity under the rug, we want to reduce it. A ternary is essentially a shorthand if-else
statement and one good design approach is to avoid writing else
statements at all.
// PromptContent.tsx
export default function PromptContent({ prompt }) {
if (!prompt.answered) {
return <AnswerForm />
}
return <AnswersList answers={prompt.answers} />
}
By inverting the condition and pulling it up, we check the faulty state and keep the golden path on the base level of indentation. This conditional statement “guards” the rest of the logic from errors and is known as a “guard clause”.
Guard clauses are one of the most useful tools we have to fight complexity at a granular level and they’ve become my favorite technique to simplify an implementation.
And with that, we’re finally ready. For now.
Summary
We covered some important concepts in great detail in this chapter. We know the importance of looking at a product holistically and architecting it based on the needs of its user-facing parts. We know how to start building, and where to mock data.
The most important takeaway is that the popular programming principles are just as applicable in the front-end as they are anywhere else in the stack. So when you’re writing code, regardless if it’s written in React or not, always think in terms of those principles - reduce complexity by splitting responsibilities, abstract domain logic, fight errors at their origin, don’t leak details from the API to the application.
Once you know a structure that works well with the technology you’re using, you can apply it as you go and have a well-structured application.