7 React Best Practices You Must Know as a Developer
Nov 9, 2023 by Florian
In this post, I will share 7 best practices that will make you a better React developer. Check them out and test how many you already knew!
There is also a video course version of this post available. You can get it for free when you sign up to my email newsletter. You can find it at https://codinginflow.com/reactbestpractices.
And here is the GitHub repository with a separate branch for each best practice: https://github.com/codinginflow/react-best-practices
Table of Contents
- Best Practice 1 - Use a modern framework
- Best Practice 2 - Derive state when possible
- Best Practice 3 - Clean up your effects
- Best Practice 4 - Clean up your fetch requests
- Best Practice 5 - Create custom hooks
- Best Practice 6 - Create hooks for your context providers
- Best Practice 7 - Use fragments to inline multiple elements
- Wrap up
Best Practice 1 - Use a modern framework
The first best practice is to use a modern React framework when you create a new project.
The React documentation lists a bunch of them on their installation page.
The framework provides the scaffolding for the project. A good framework makes sure that everything works out of the box. This is especially important when you want advanced features like the new React server components, which are not trivial to wire up. Good frameworks also add their own, additional features on top of the core React functionality. Different frameworks have different strengths and weaknesses.
My favorite is Next.js, which is a full-stack React framework that comes with server components, server actions, an integrated routing system, automatic image optimization, and other features. To set up a new Next.js project, you run a simple CLI command: npx create-next-app@latest
. Just take a look at this huge list of companies that use Next.js. Big names like Nike and Netflix are on there.
Another tip is to use TypeScript for your projects. It has a bit of a learning curve when you're coming from JavaScript, but it makes working with large projects infinitely easier.
Best Practice 2 - Derive state when possible
When you have a value that is calculated from other values, you don't need to create a separate state and effect for that:
function MyComponent() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
// Unnecessary
const [fullName, setFullName] = useState("");
// Unnecessary
useEffect(() => {
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);
[...]
}
This is a common beginner mistake. Not only does the useEffect cause an unnecessary additional rerender, the code is also difficult to understand. What's worse: If you modify the fullName
state directly, it can even get out of sync with the firstName
and lastName
values.
The correct solution is to simply calculate the state directly in the body of the component:
function MyComponent() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const fullName = firstName + " " + lastName;
[...]
}
You should use the same approach whenever you can calculate a value from existing state. This includes things like filtering lists.
Yes, this means the calculation will be executed on every render. For simple operations, like the string concatenation above, this doesn't matter. If you do a more expensive operation, wrap it into useMemo
:
function MyComponent() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const fullName = firstName + " " + lastName;
const vowelsMemoized = useMemo(() => {
const chars = fullName.split("");
const vowels = chars.filter((char) => "aeiou".includes(char));
return vowels;
}, [fullName]); // Block only runs again when fullName changes
[...]
}
In the snippet above, we count the vowels in the user's name. It's just a random example I came up with. We still don't need a useEffect or useState. But since this operation is a bit more CPU-intensive, I wrapped it into useMemo. Note the dependency array that contains only fullName
. This means the vowels value will only be calculated again when fullName
changes. In every render between that, it will reuse the value it calculated before.
Don't use useMemo prematurely because it makes your code more complex. Only use it when you actually do a heavy calculation.
Best Practice 3 - Clean up your effects
You might have noticed that React renders your components twice in development. This is a feature, not a bug. It is part of React's strict mode, which is enabled by default in most React frameworks. It protects you from effects that don't clean up after themselves. Strict mode is only active in development, not in production.
The following useEffect has an empty dependency array, so it runs when the component mounts:
function MyComponent() {
useEffect(() => {
createConnection();
}, []);
[...]
}
But when we take a look at the logs in development, we can see that it connected twice:
This, again, happens because of React's strict mode. But of course, there is a good reason it does that. Even in production, React can unmount and remount your component multiple times, for example, on navigation. Every time this happens, this useEffect runs again and this way creates multiple connections. This is almost never what you want.
The solution to this problem is to clean up our useEffect by returning a cleanup function from it. This function will run before the useEffect runs again and right before the component is unmounted. Here, we can close our connection, or perform any other cleanup:
function MyComponent() {
useEffect(() => {
createConnection();
// Cleanup function
return () => closeConnection();
}, []);
[...]
}
Now the useEffect still runs twice in development, but it closes the previous connection before creating a new one. This way, there will only ever be one active connection at a time:
Use cleanup functions whenever your effect sets something up that should only be active once, like connections or event listeners:
useEffect(() => {
function handleKeyPress(e: KeyboardEvent) {
console.log("key pressed", e.key);
}
window.addEventListener("keypress", handleKeyPress);
// Clean up your event listeners or they will stack up:
return () => window.removeEventListener("keypress", handleKeyPress);
}, []);
Best Practice 4 - Clean up your fetch requests
The following code uses a useEffect to search for products whenever the user changes the input in the search field. Can you detect the problem with this code?
import { useEffect, useState } from "react";
export default function SearchPage() {
const [searchInput, setSearchInput] = useState("");
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
async function searchProducts() {
const products = await fetchProducts(searchInput);
setProducts(products);
}
if (searchInput) {
searchProducts();
} else {
setProducts([]);
}
}, [searchInput]);
return (
<div>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search products..."
/>
<div>
{products.map((product) => (
<div className="..." key={product.id}>
<img
className="..."
src={product.thumbnail}
alt="Product thumbnail"
/>
<p>{product.title}</p>
</div>
))}
</div>
</div>
);
}
At first glance, this looks fine. But what happens if fetchProducts
sometimes runs slow? This will cause race conditions where the fetch response of a previous search query arrives after the response of the letters we typed in afterwards. These race conditions are more likely with a flaky internet connection and they make the UI glitch noticeably:
Doesn't look great, does it? To fix this problem, you have to introduce a boolean flag that ignores the previous fetch request when the useEffect runs again. For this, we use the same useEffect cleanup function we learned about in the previous lesson:
useEffect(() => {
let ignore = false;
async function searchProducts() {
const products = await fetchProducts(searchInput);
if (!ignore) {
setProducts(products);
}
}
if (searchInput) {
searchProducts();
} else {
setProducts([]);
}
return () => {
ignore = true;
};
}, [searchInput]);
The ignore
flag above is scoped to the useEffect itself. So every instance of this useEffect will have its own ignore
variable.
With this setup, whenever the useEffect runs again, the previous fetch request will simply be ignored.
Of course, this code is not very pretty. Especially when you also start adding loading and error states. This is why many people choose a dedicated fetching library like SWR or React-query instead of writing their own fetch requests inside useEffects. These libraries not only handle race conditions for you, they also add other useful features like caching, automatic revalidation, and more.
Best Practice 5 - Create custom hooks
When the logic in your React components becomes too big and complex, of course, you want to start moving it into separate files or functions.
All React functions that start with the word "use", like useEffect, useState, and useMemo, are called hooks in React. Hooks can only be called inside React components or other hooks.
To create a custom hook, you simply create a function that starts with the word "use". If you try to call a hook like useState or useEffect in a function that doesn't start with "use", ESLint will show an error. React enforces this naming convention so that developers can easily see which functions use hooks internally.
Let's move the logic of the previous example into a custom hook, including the useState and the useEffect with our cleanup flag:
export default function useSearchProducts(searchInput: string) {
const [products, setProducts] = useState<Product[]>();
useEffect(() => {
let ignore = false;
async function searchProducts() {
const products = await fetchProducts(searchInput);
if (!ignore) {
setProducts(products);
}
}
if (searchInput) {
searchProducts();
} else {
setProducts([]);
}
return () => {
ignore = true;
};
}, [searchInput]);
// You can return values from the hook
return { products };
}
In our component, we simply call this function, pass the search input to it, and destructure the return values we care about. This makes our code not only more readable but also more declarative because all the steps of the fetching process are abstracted away:
// In your components:
const { products } = useSearchProducts(searchInput);
Hooks are a great way to add separation of concerns to your React components and make component logic reusable.
Best Practice 6 - Create hooks for your context providers
Creating custom hooks is also great for making your own context providers easier to use. And they provide a nice way to handle undefined context values. To see what I mean, take a look at this context provider:
"use client";
import { createContext, useContext, useState } from "react";
interface ToastContext {
showToast: (message: string) => void;
}
// Problem: No valid default value
export const ToastContext = createContext<ToastContext | undefined>(undefined);
export default function ToastProvider({
children,
}: {
children: React.ReactNode;
}) {
const [toastMessage, setToastMessage] = useState("");
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
function showToast(message: string) {
setToastMessage(message);
if (timeoutId) {
clearTimeout(timeoutId);
}
const newTimeoutId = setTimeout(() => {
setToastMessage("");
}, 3000);
setTimeoutId(newTimeoutId);
}
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className={`... ${toastMessage ? "visible" : "invisible"}`}>
{toastMessage}
</div>
</ToastContext.Provider>
);
}
If you have never created your own React context before, here is a quick rundown of the single steps. We also build various context providers in my web development playlist on YouTube, where I give more detailed explanations.
The important part is the line towards the top that calls createContext
. This function expects a default value that will be used if we try to access this context in a component that's not wrapped into the ToastProvider
.
The problem is, often we don't have a meaningful default value that we can pass here. If we pass undefined as the default value, we have to handle a possibly undefined value whenever we access this context:
"use client";
import { useContext } from "react";
import { ToastContext } from "./ToastProvider";
export default function Page() {
// Access the context
const toastContext = useContext(ToastContext);
return (
<main>
<button
// We have to use the safe-call operator because the context could be undefined:
onClick={() => toastContext?.showToast("hello world")}
>
Show toast
</button>
</main>
);
}
Not only does this look ugly, but we can also not use object destructuring on the toastContext
because it could be undefined. And what's worse: If we indeed forget to wrap this component into the ToastProvider
, the showToast
function will just silently fail and do nothing. This can cause hard-to-detect bugs.
For these reasons, and for readability, you should always create custom hooks for your context providers:
// Custom hook
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}
// Usage:
const { showToast } = useToast();
showToast("hello world")
Not only does this hook make our code more readable and our context easier to use, we also get a clear error message as soon as we open the page if we forgot to wrap this component into a ToastProvider. You always want your errors to show up as early as possible so you can quickly detect them.
Since the toast context is now guaranteed to be defined, we can also destructure the showToast
function out of it.
You can put this hook in the same file as your context provider, that's what I like to do.
Best Practice 7 - Use fragments to inline multiple elements
Because of the syntax rules of JSX code, you sometimes need a single parent element in a place where you actually want to render multiple sibling elements. Here is an example:
function UserProfile() {
const [showAddress, setShowAddress] = useState(false);
return (
<main className="p-4 space-y-1.5">
<p>{user.name}</p>
<p>{user.age}</p>
<button onClick={() => setShowAddress(true)}>
Show Address
</button>
{showAddress && (
<div>
<p>{user.address.street}</p>
<p>{user.address.number}</p>
<p>{user.address.city}</p>
<p>{user.address.state}</p>
</div>
)}
</main>
);
}
Because the user's address is rendered conditionally inside an expression (showAddress &&
), the code we put into the expression needs a single parent tag. If we remove the inner div, the code will not compile.
The problem is, now we have an unnecessary div in our HTML tree that also cancels out the styling we applied to the surrounding main
tag. The space-y-1.5
class is not applied to the address paragraphs. Of course, we could add the same space-y
class to the nested div, but this makes our code unnecessarily convoluted.
The better solution is to use a fragment instead of a div, by using an empty pair of <> </>
:
function UserProfile() {
const [showAddress, setShowAddress] = useState(false);
return (
<main className="p-4 space-y-1.5">
<p>{user.name}</p>
<p>{user.age}</p>
<button onClick={() => setShowAddress(true)}>
Show Address
</button>
{showAddress && (
<>
<p>{user.address.street}</p>
<p>{user.address.number}</p>
<p>{user.address.city}</p>
<p>{user.address.state}</p>
</>
)}
</main>
);
}
The fragment provides a single parent element so that the compiler doesn't complain. However, the fragment will disappear from the resulting HTML. All the styling of the main tag will be applied directly to the address paragraphs:
Wrap up
I hope you enjoyed my little list of React best practices. Again, there is a video version of this course available for free at https://codinginflow.com/reactbestpractices.
And you can find all my free long-form web development tutorials in this YouTube playlist.
See you in the next post! Happy coding!
Florian
Tip: I send regular high-quality web development content to my free email newsletter. It's a great way to improve your skills and stay ahead of the curve.
Subscribe Now