How to write good React code?
I was debugging useEffect
hooks in the app I developed a long time ago. I saw an evil mixture of one-mile long dependency array, coupled logic, and repetitive code created by some crazy alchemist (me from the past). It shouldn't be like that, we can do better!
I am not a React guru, and don't pretend I know the single correct approach to creating React applications. Instead of giving you a lecture, I will share some observations about what helps me create React apps I love working with. I've built dozens of React applications, both professionally and for fun, and I got a few bruises along the way. Today I'll tell what I do to protect myself from my mistakes from the past.
Writing good React code means keeping an eye on a dozen of small details that are only partially related to React! It's about subtle techniques that can be applied to an application built with any tools.
Let's start!
Use useEffect mindfully
If you've worked with React long enough, chances are, you've encountered useEffect
hooks that still haunt you at night! It's pretty hard to master useEffect
hooks, and I am not sure I mastered them myself yet. Not using useEffect
hooks sparingly may lead to hard to catch bugs that will give you bad time!
useEffect when necessary
React documentation has a wonderful section about useEffect
hooks. Make sure to bookmark this page and get back to it when you have some time in the morning to read it over a cup of warm black cofee, maybe with just a drizzle of cream βοΈ
useEffect
is a very powerful concept that gives you a lot of freedom. To protect your application and yourself from troubles, use useEffect
only when it matches the original use case:
Synchronize a component with an external system.
The external system can be anything from API (the server you send requests to) to Web API (device battery charge, index db, or other services). When you need to synchronize your component with the changes coming from these external sources, that's when you need useEffect
!
As React developers say, You Might Not Need an Effect. Another brilliant article to enjoy!
Clean function should mirror setup function
I like to think about useEffect
as something I have absolutely no control over! When I adopt this mindset, I create useEffect
calls that can't break my components and here is how I do it.
useEffect(() => [
function handler(e) {
console.log(e)
}
window.addEventLister("resize", handler)
return () => window.removeEventLister("resize", handler) // this is cleanup function
], [])
Now, even if this useEffect
hook goes absolutely nuts and gets triggered a million times, I am fine! Because at the end of the day, every call was cancelled by it's own cleanup function. Even if some nasty bug gets into our hook, we are good and sound!
Sometimes it's impossible to create a cleanup function like that just because you introduce side effects that can't be undone. That's OK! Try and do your best and move forward if you can't make it work. Let's see what tricks we have left up our sleeve.
Shorter dependency array
This technique really shines when applied to the mount useEffect
hooks. I call it mount useEffect
because you only want a setup function to run once when a component is mounted. If you haven't heard the term setup function, don't sweat it! It's simply a callback function, you pass into the useEffect
hook.
It's inredibly easy to create a mount hook that runs a setup function more than once just because useEffect
is very flexible without many restrictions from the React dev team (similar to Javascript in a way βΊοΈ).
Sidenote: Some people who worked with previous versions of React might remember the componentDidMount
lifecycle hook. When you use useEffect
to mimic this behavior, that's the case we are looking at right now!
I don't have a good example for this case because I will just throw a huge useEffect
at you, and you will hate me for that. Instead, I will just mention the most common issue with having a hook like that in your app: it runs multiple times instead of running once on mount.
Sidenote: React does stress testing for your components when your app is in the DEV mode (it's mentioned on the useEffect
page). It runs useEffect
setup function twice on mount. I once spent half my day trying to understand what was causing it so consider yourself warned!
A few things I do when I see my dependency array gets too long:
- See if I can get rid of the hook using the tips from You Might Not Need an Effect
- Try and split my hook into two or three separate
useEffect
hooks with short dependency arrays
If it's not possible to condense the dependency array, I consider other options:
- Passing primitives values (string, number, boolean) instead of objects
- Using
useMemo
anduseCallback
when I absolutely need to pass objects and functions
Pass primitives to have more control
I can't be sure everyone understands how shallow comparison vs deep comparison work in Javascript so I'll give a super quick intro to the concept first.
1 === 1 // true
'1' === '1' // true
{} === {} // false
Wait... Why isn't the empty object "equal" to another empty object?
The reason for behavior is how Javascript checks the equality of primitive values vs objects. The primitive values (string, number, boolean) are compared by their value. On the other hand, objects are compared by reference, a link to the memory address the object is stored in. Let's see the example again, but through a magnifying lens π§
1 === 1; // true
"1" === "1"; // true
0x7ffd2e4a1234 === 0x5ac31c4c9876; // false
Now we're all on the same page β at least, sort of. I'm not π― confident in my teaching abilities, but hopefully, you got the big picture!
So you have this nice blog application that attracts hundreds of thousands of people due to it's authenticity and fine content. You are making it even more engaging by adding an onboarding for your users to get them familiar with the new features you've recently added. Let's see how a component for your home page might have looked!
import { Article } from "./Article";
import { getUserUsername } from "./services/user.service";
function getIsUserOnboarded(user: User) {
return user.isOnboarded;
}
function Blog() {
const { user } = useContext;
// this hook should run on mount only, but we can't guarantee that!
// what if user object was updated?
// this hook will run every time the user object updates!
// and we don't know when exactly because it's defined outside our component
useEffect(
() => {
if (!getIsUserOnboarded(user)) {
window.location.replace("/?onboarding=true");
}
},
[],
[user],
);
return (
<main>
<header>Articles From: {getUserUsername(user)}</header>
<Article user={user}></Article>
</main>
);
}
We want our setup function to be called only when the component is mounted, but we can't guarantee that! Every time the user
object is updated, your setup function will be called and that's not what we want!
Why would the user object even change?
React team promotes functional programming and immutability is one of it's core concepts. Immutability means your objects can't be changed! When you need to update an object, you create a brand new object based on the previous object's values.
// this is mutable
const user = {
id: 1,
};
uers.id = 2;
// this is immutable
const user = { id: 1 };
const newUser = { ...user, id: 2 };
As you can see, when we change the existing property on the user
object, or add a new one, a new object is created. As we saw earlier, two objects created separately will never be equal to one another because their references (memory addresses) will be different.
So, what do we do? How can we ensure the useEffect
runs only once when the component is mounted?
We can pass primitive value instead!
const isOnboarded = getIsUserOnboarded(user);
useEffect(
() => {
if (!isOnboarded) {
window.location.replace("/?onboarding=true");
}
},
[],
[isOnboarded],
);
This way, you can be more in control of useEffect
hooks in your components!
Minimize coupling between business logic and components
In React, components are re-usable chunks of UI that can benefit from NOT knowing anything about your business logic!
What is business logic?
It's tricky to put it into words and not sound overcomplicated, but bare with me! Forget about UI, React, components and everything related to how users interact with your app. Now, explain in plain English how your app works. Every entity, operation, and process you mention is a part of your business logic!
Say you have a blog where users can put a like to your article. How would you describe the business logic of this process? Probably something like this: "A user gives a like to an article".
In this short sentence, we defined two entities: User and Article and an action β Like. In your Typescript code, you could have defined it like this:
interface User {
id: string;
username: string;
}
interface Article {
id: string;
title: string;
content: string;
}
type likeArticle = (user: User, article: Article) => void;
In your UI, you can unleash your creativity and visualize this process in any way that suits you best!
Why not couple business logic and UI?
Things change and they change fast! It's a good idea to keep something that will most certainly change separately from your components. This way, you can ensure your components are bullet proof, and you don't have to revise them every time you are changing your business logic.
But why business logic changes?
You add features all the time! Your application grows more complex as it evolves to meet market demand. As you continue adding value for your users, changes in business logic become inevitable!
Let's continue by using the example of our blog application introduced in the previous section. You are adding a list of articles to your website. Let's see how it could look like in the code!
// user.types.ts
interface User {
id: string;
username: string;
}
// Blog.tsx
import { Article } from "./Article";
function Blog() {
const { user } = useContext;
return (
<main>
<header>Articles From: {user.username}</header>
<Article user={user}></Article>
</main>
);
}
// Article.tsx
function Article({ user }: ArticleProps) {
return <article>by: {user.username}</article>;
}
Huh... The code looks perfectly normal! I can't see any room for improvement...
Your components already know a little more than they should! Take a closer look to how we get the properties from the user
object. It might come from a server that already has a predefined structure for the user
object. This means that if the server changes the structure of the user
object, you will need to update all your components that rely on its structure.
But why will the structure change? Can't the server keep the same structure for me?
Backend developers will strive to maintain consistent interfaces for as long as possible, but itβs not always easy. As the app evolves and business logic changes, the interfaces will inevitably change as well!
The blog example we just discussed might be overly simplified. You could change two lines of code to address the change of the user
object, but it's not the case for the bigger repositories!
So... What can I do to ensure my components are safer when business logic changes?
Use services.
In React application, service can be a Javascript file with functions that work with business logic. Components use service functions and are not aware of the structure of your entities! This way, if the structure of the user object from the example above changes on the server, we only need to update it in the service file! All the components will automatically keep up with the change.
Now, let's see how the component that uses service functions could potentially look like π€
// user.types.ts
interface User {
id: string;
username: string;
}
// services/user.service.ts
function getUserUsername(user: User) {
return user.username;
}
// Blog.tsx
import { Article } from "./Article";
import { getUserUsername } from "./services/user.service";
function Blog() {
const { user } = useContext;
return (
<main>
<header>Articles From: {getUserUsername(user)}</header>
<Article user={user}></Article>
</main>
);
}
// Article.tsx
import { getUserUsername } from "./services/user.service";
function Article({ user }: ArticleProps) {
return <article>by: {getUserUsername(user)}</article>;
}
Blog
and Article
components are now unaware of the structure of the user
object. This is great! If the server changes the structure, I won't have to search through the entire codebase in a panic, hoping I didn't miss a line that could dramatically blow up the entire app π
In reality, business logic is far more nuanced than simply picking a few properties from an object β but we're all set! You can compose service functions to build more complex logic.
// services/user.ts
function getUserId(user: User) {
return user.id;
}
// services/auth.ts
function getUserIdFromSso(sso: SSO) {
return sso.subject.properties.user_id;
}
// services/user-auth.ts
import { getUserId } from "./services/user.ts";
import { getUserIdFromSso } from "./services/auth.ts";
function ensureUserHasAccess(user: User, sso: SSO) {
return getUserId(user) === getUserIdFromSso(sso);
}
I use service functions whenever I need to retrieve properties from entities or perform some logic on them. This way, I can be confident that I won't need to search through my whole codebase to find where the entity is used in every component. When the change is required, I only adjust my service function and sleep sound after that! π΄
OK, what else is on the radar?
Do One Thing and Do It Well
I keep my components short and focused on a single thing. Having a few strategies for when break your components helps me avoid overthinking things!
Break it when markup is too verbose
I have a silly example of a Blog
component, which includes navigation markup. In real world, you could just use a .map
function to iterate over the ["Home", "Posts", "Explore"]
array to not repeat yourself and make the component shorter. That being said, this example works fine to illustrate how reducing markup increases readability.
// with navigation markup
function Blog({ articles }: BlogProps) {
const { user } = useContext(UserContext);
return (
<div>
<header>
<nav>
<ul>
<li>
<a href="/home">Home</a>
</li>
<li>
<a href="/posts">Posts</a>
</li>
<li>
<a href="/explore">Explore</a>
</li>
</ul>
</nav>
</header>
<main>
{articles.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</main>
</div>
);
}
// without navigation markup
function Blog({ articles }: BlogProps) {
return (
<div>
<header>
<BlogNavigation />
</header>
<main>
<ul>
{articles.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
</main>
</div>
);
}
It's much easier to read the component now, but take it with a grain of salt and avoid overdoing it. When I separate every small portion of markup into its own component, it becomes a nightmare to work in a codebase like this! I need to keep dozens of tabs with tiny components open in my IDE. Navigating a codebase like this is hard!
Listening to my gut feeling when splitting components into smaller ones helps a big deal to not overdo it!
Break it when you want to re-use it
Let's take a look at our Blog
example again. Your blog grows and you want to add a new page now! Congratulations, but let's plan for this change. The new page is very similar to the Blog
page. It has a header, main content and a footer. We definitely don't want to just copy the code for header and footer and paste it to the new page.
Okay, but why not just copy paste?
It's tempting sometimes, but let's say you have three pages on your blog. If you copy and paste the code, you now have three copies of the code where your header and footer markup live. And here's the tricky part: you now want to change how your header looks. You'd have to make that change in all three places! And if you add more pages, you'll be repeating yourself a lot!
Again, I make sure to not take this to extreme. Otherwise, I will end up having dozens of IDE tabs open with tiny components which will make the navigation incredibly hard!
Au revoir
You've made it! πββοΈ
In this article, we saw my take on how to write good React code by ensuring the components do one thing and one thing only, preventing coupling business logic with your components, and keeping your useEffect
hooks nice and lean.
These recipes are not an exact science, so feel free to experiment and see what works best for you! It's the creative part and attention to details that make your React code clean and maintainable.
I'll see you next time!
If you want to support me, please-please visit my star my GitHub Profile and star a repository you like. This will help me grow my presence in the open-source community and unlock new opportunities! If you don't feel like it, it's fine, you don't have to do it! I'll continue to write new articles both with and without it!